From 2e7b4f2027a1147ca28301e4f88adf8274b39a1f Mon Sep 17 00:00:00 2001 From: DUVAL Thomas Date: Thu, 9 Jun 2016 09:11:50 +0200 Subject: Update Keystone core to Mitaka. Change-Id: Ia10d6add16f4a9d25d1f42d420661c46332e69db --- .../keystone/resource/V8_backends/__init__.py | 0 keystone-moon/keystone/resource/V8_backends/sql.py | 260 ++++ keystone-moon/keystone/resource/__init__.py | 1 - keystone-moon/keystone/resource/backends/sql.py | 239 ++-- .../keystone/resource/config_backends/sql.py | 28 +- keystone-moon/keystone/resource/controllers.py | 62 +- keystone-moon/keystone/resource/core.py | 1321 +++++++++++++++----- keystone-moon/keystone/resource/routers.py | 31 + keystone-moon/keystone/resource/schema.py | 8 +- 9 files changed, 1508 insertions(+), 442 deletions(-) create mode 100644 keystone-moon/keystone/resource/V8_backends/__init__.py create mode 100644 keystone-moon/keystone/resource/V8_backends/sql.py (limited to 'keystone-moon/keystone/resource') diff --git a/keystone-moon/keystone/resource/V8_backends/__init__.py b/keystone-moon/keystone/resource/V8_backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/keystone-moon/keystone/resource/V8_backends/sql.py b/keystone-moon/keystone/resource/V8_backends/sql.py new file mode 100644 index 00000000..6c9b7912 --- /dev/null +++ b/keystone-moon/keystone/resource/V8_backends/sql.py @@ -0,0 +1,260 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log + +from keystone.common import clean +from keystone.common import driver_hints +from keystone.common import sql +from keystone import exception +from keystone.i18n import _LE +from keystone import resource as keystone_resource + + +LOG = log.getLogger(__name__) + + +class Resource(keystone_resource.ResourceDriverV8): + + def default_assignment_driver(self): + return 'sql' + + def _get_project(self, session, project_id): + project_ref = session.query(Project).get(project_id) + if project_ref is None: + raise exception.ProjectNotFound(project_id=project_id) + return project_ref + + def get_project(self, tenant_id): + with sql.session_for_read() as session: + return self._get_project(session, tenant_id).to_dict() + + def get_project_by_name(self, tenant_name, domain_id): + with sql.session_for_read() as session: + query = session.query(Project) + query = query.filter_by(name=tenant_name) + query = query.filter_by(domain_id=domain_id) + try: + project_ref = query.one() + except sql.NotFound: + raise exception.ProjectNotFound(project_id=tenant_name) + return project_ref.to_dict() + + @driver_hints.truncated + def list_projects(self, hints): + with sql.session_for_read() as session: + query = session.query(Project) + project_refs = sql.filter_limit_query(Project, query, hints) + return [project_ref.to_dict() for project_ref in project_refs] + + def list_projects_from_ids(self, ids): + if not ids: + return [] + else: + with sql.session_for_read() as session: + query = session.query(Project) + query = query.filter(Project.id.in_(ids)) + return [project_ref.to_dict() for project_ref in query.all()] + + def list_project_ids_from_domain_ids(self, domain_ids): + if not domain_ids: + return [] + else: + with sql.session_for_read() as session: + query = session.query(Project.id) + query = ( + query.filter(Project.domain_id.in_(domain_ids))) + return [x.id for x in query.all()] + + def list_projects_in_domain(self, domain_id): + with sql.session_for_read() as session: + self._get_domain(session, domain_id) + query = session.query(Project) + project_refs = query.filter_by(domain_id=domain_id) + return [project_ref.to_dict() for project_ref in project_refs] + + def _get_children(self, session, project_ids): + query = session.query(Project) + query = query.filter(Project.parent_id.in_(project_ids)) + project_refs = query.all() + return [project_ref.to_dict() for project_ref in project_refs] + + def list_projects_in_subtree(self, project_id): + with sql.session_for_read() as session: + children = self._get_children(session, [project_id]) + subtree = [] + examined = set([project_id]) + while children: + children_ids = set() + for ref in children: + if ref['id'] in examined: + msg = _LE('Circular reference or a repeated ' + 'entry found in projects hierarchy - ' + '%(project_id)s.') + LOG.error(msg, {'project_id': ref['id']}) + return + children_ids.add(ref['id']) + + examined.update(children_ids) + subtree += children + children = self._get_children(session, children_ids) + return subtree + + def list_project_parents(self, project_id): + with sql.session_for_read() as session: + project = self._get_project(session, project_id).to_dict() + parents = [] + examined = set() + while project.get('parent_id') is not None: + if project['id'] in examined: + msg = _LE('Circular reference or a repeated ' + 'entry found in projects hierarchy - ' + '%(project_id)s.') + LOG.error(msg, {'project_id': project['id']}) + return + + examined.add(project['id']) + parent_project = self._get_project( + session, project['parent_id']).to_dict() + parents.append(parent_project) + project = parent_project + return parents + + def is_leaf_project(self, project_id): + with sql.session_for_read() as session: + project_refs = self._get_children(session, [project_id]) + return not project_refs + + # CRUD + @sql.handle_conflicts(conflict_type='project') + def create_project(self, tenant_id, tenant): + tenant['name'] = clean.project_name(tenant['name']) + with sql.session_for_write() as session: + tenant_ref = Project.from_dict(tenant) + session.add(tenant_ref) + return tenant_ref.to_dict() + + @sql.handle_conflicts(conflict_type='project') + def update_project(self, tenant_id, tenant): + if 'name' in tenant: + tenant['name'] = clean.project_name(tenant['name']) + + with sql.session_for_write() as session: + tenant_ref = self._get_project(session, tenant_id) + old_project_dict = tenant_ref.to_dict() + for k in tenant: + old_project_dict[k] = tenant[k] + new_project = Project.from_dict(old_project_dict) + for attr in Project.attributes: + if attr != 'id': + setattr(tenant_ref, attr, getattr(new_project, attr)) + tenant_ref.extra = new_project.extra + return tenant_ref.to_dict(include_extra_dict=True) + + @sql.handle_conflicts(conflict_type='project') + def delete_project(self, tenant_id): + with sql.session_for_write() as session: + tenant_ref = self._get_project(session, tenant_id) + session.delete(tenant_ref) + + # domain crud + + @sql.handle_conflicts(conflict_type='domain') + def create_domain(self, domain_id, domain): + with sql.session_for_write() as session: + ref = Domain.from_dict(domain) + session.add(ref) + return ref.to_dict() + + @driver_hints.truncated + def list_domains(self, hints): + with sql.session_for_read() as session: + query = session.query(Domain) + refs = sql.filter_limit_query(Domain, query, hints) + return [ref.to_dict() for ref in refs] + + def list_domains_from_ids(self, ids): + if not ids: + return [] + else: + with sql.session_for_read() as session: + query = session.query(Domain) + query = query.filter(Domain.id.in_(ids)) + domain_refs = query.all() + return [domain_ref.to_dict() for domain_ref in domain_refs] + + def _get_domain(self, session, domain_id): + ref = session.query(Domain).get(domain_id) + if ref is None: + raise exception.DomainNotFound(domain_id=domain_id) + return ref + + def get_domain(self, domain_id): + with sql.session_for_read() as session: + return self._get_domain(session, domain_id).to_dict() + + def get_domain_by_name(self, domain_name): + with sql.session_for_read() as session: + try: + ref = (session.query(Domain). + filter_by(name=domain_name).one()) + except sql.NotFound: + raise exception.DomainNotFound(domain_id=domain_name) + return ref.to_dict() + + @sql.handle_conflicts(conflict_type='domain') + def update_domain(self, domain_id, domain): + with sql.session_for_write() as session: + ref = self._get_domain(session, domain_id) + old_dict = ref.to_dict() + for k in domain: + old_dict[k] = domain[k] + new_domain = Domain.from_dict(old_dict) + for attr in Domain.attributes: + if attr != 'id': + setattr(ref, attr, getattr(new_domain, attr)) + ref.extra = new_domain.extra + return ref.to_dict() + + def delete_domain(self, domain_id): + with sql.session_for_write() as session: + ref = self._get_domain(session, domain_id) + session.delete(ref) + + +class Domain(sql.ModelBase, sql.DictBase): + __tablename__ = 'domain' + attributes = ['id', 'name', 'enabled'] + id = sql.Column(sql.String(64), primary_key=True) + name = sql.Column(sql.String(64), nullable=False) + enabled = sql.Column(sql.Boolean, default=True, nullable=False) + extra = sql.Column(sql.JsonBlob()) + __table_args__ = (sql.UniqueConstraint('name'),) + + +class Project(sql.ModelBase, sql.DictBase): + __tablename__ = 'project' + attributes = ['id', 'name', 'domain_id', 'description', 'enabled', + 'parent_id', 'is_domain'] + id = sql.Column(sql.String(64), primary_key=True) + name = sql.Column(sql.String(64), nullable=False) + domain_id = sql.Column(sql.String(64), sql.ForeignKey('domain.id'), + nullable=False) + description = sql.Column(sql.Text()) + enabled = sql.Column(sql.Boolean) + extra = sql.Column(sql.JsonBlob()) + parent_id = sql.Column(sql.String(64), sql.ForeignKey('project.id')) + is_domain = sql.Column(sql.Boolean, default=False, nullable=False, + server_default='0') + # Unique constraint across two columns to create the separation + # rather than just only 'name' being unique + __table_args__ = (sql.UniqueConstraint('domain_id', 'name'),) diff --git a/keystone-moon/keystone/resource/__init__.py b/keystone-moon/keystone/resource/__init__.py index c0070a12..7f879f4b 100644 --- a/keystone-moon/keystone/resource/__init__.py +++ b/keystone-moon/keystone/resource/__init__.py @@ -12,4 +12,3 @@ from keystone.resource import controllers # noqa from keystone.resource.core import * # noqa -from keystone.resource import routers # noqa diff --git a/keystone-moon/keystone/resource/backends/sql.py b/keystone-moon/keystone/resource/backends/sql.py index 59bab372..39bb4f3b 100644 --- a/keystone-moon/keystone/resource/backends/sql.py +++ b/keystone-moon/keystone/resource/backends/sql.py @@ -10,87 +10,123 @@ # License for the specific language governing permissions and limitations # under the License. -from oslo_config import cfg from oslo_log import log from keystone.common import clean +from keystone.common import driver_hints from keystone.common import sql from keystone import exception -from keystone.i18n import _LE +from keystone.i18n import _LE, _LW from keystone import resource as keystone_resource -CONF = cfg.CONF LOG = log.getLogger(__name__) -class Resource(keystone_resource.ResourceDriverV8): +class Resource(keystone_resource.ResourceDriverV9): def default_assignment_driver(self): return 'sql' + def _encode_domain_id(self, ref): + if 'domain_id' in ref and ref['domain_id'] is None: + new_ref = ref.copy() + new_ref['domain_id'] = keystone_resource.NULL_DOMAIN_ID + return new_ref + else: + return ref + + def _is_hidden_ref(self, ref): + return ref.id == keystone_resource.NULL_DOMAIN_ID + def _get_project(self, session, project_id): project_ref = session.query(Project).get(project_id) - if project_ref is None: + if project_ref is None or self._is_hidden_ref(project_ref): raise exception.ProjectNotFound(project_id=project_id) return project_ref - def get_project(self, tenant_id): - with sql.transaction() as session: - return self._get_project(session, tenant_id).to_dict() + def get_project(self, project_id): + with sql.session_for_read() as session: + return self._get_project(session, project_id).to_dict() - def get_project_by_name(self, tenant_name, domain_id): - with sql.transaction() as session: + def get_project_by_name(self, project_name, domain_id): + with sql.session_for_read() as session: query = session.query(Project) - query = query.filter_by(name=tenant_name) - query = query.filter_by(domain_id=domain_id) + query = query.filter_by(name=project_name) + if domain_id is None: + query = query.filter_by( + domain_id=keystone_resource.NULL_DOMAIN_ID) + else: + query = query.filter_by(domain_id=domain_id) try: project_ref = query.one() except sql.NotFound: - raise exception.ProjectNotFound(project_id=tenant_name) + raise exception.ProjectNotFound(project_id=project_name) + + if self._is_hidden_ref(project_ref): + raise exception.ProjectNotFound(project_id=project_name) return project_ref.to_dict() - @sql.truncated + @driver_hints.truncated def list_projects(self, hints): - with sql.transaction() as session: + # If there is a filter on domain_id and the value is None, then to + # ensure that the sql filtering works correctly, we need to patch + # the value to be NULL_DOMAIN_ID. This is safe to do here since we + # know we are able to satisfy any filter of this type in the call to + # filter_limit_query() below, which will remove the filter from the + # hints (hence ensuring our substitution is not exposed to the caller). + for f in hints.filters: + if (f['name'] == 'domain_id' and f['value'] is None): + f['value'] = keystone_resource.NULL_DOMAIN_ID + with sql.session_for_read() as session: query = session.query(Project) project_refs = sql.filter_limit_query(Project, query, hints) - return [project_ref.to_dict() for project_ref in project_refs] + return [project_ref.to_dict() for project_ref in project_refs + if not self._is_hidden_ref(project_ref)] def list_projects_from_ids(self, ids): if not ids: return [] else: - with sql.transaction() as session: + with sql.session_for_read() as session: query = session.query(Project) query = query.filter(Project.id.in_(ids)) - return [project_ref.to_dict() for project_ref in query.all()] + return [project_ref.to_dict() for project_ref in query.all() + if not self._is_hidden_ref(project_ref)] def list_project_ids_from_domain_ids(self, domain_ids): if not domain_ids: return [] else: - with sql.transaction() as session: + with sql.session_for_read() as session: query = session.query(Project.id) query = ( query.filter(Project.domain_id.in_(domain_ids))) - return [x.id for x in query.all()] + return [x.id for x in query.all() + if not self._is_hidden_ref(x)] def list_projects_in_domain(self, domain_id): - with sql.transaction() as session: - self._get_domain(session, domain_id) + with sql.session_for_read() as session: + try: + self._get_project(session, domain_id) + except exception.ProjectNotFound: + raise exception.DomainNotFound(domain_id=domain_id) query = session.query(Project) - project_refs = query.filter_by(domain_id=domain_id) + project_refs = query.filter(Project.domain_id == domain_id) return [project_ref.to_dict() for project_ref in project_refs] - def _get_children(self, session, project_ids): + def list_projects_acting_as_domain(self, hints): + hints.add_filter('is_domain', True) + return self.list_projects(hints) + + def _get_children(self, session, project_ids, domain_id=None): query = session.query(Project) query = query.filter(Project.parent_id.in_(project_ids)) project_refs = query.all() return [project_ref.to_dict() for project_ref in project_refs] def list_projects_in_subtree(self, project_id): - with sql.transaction() as session: + with sql.session_for_read() as session: children = self._get_children(session, [project_id]) subtree = [] examined = set([project_id]) @@ -111,7 +147,7 @@ class Resource(keystone_resource.ResourceDriverV8): return subtree def list_project_parents(self, project_id): - with sql.transaction() as session: + with sql.session_for_read() as session: project = self._get_project(session, project_id).to_dict() parents = [] examined = set() @@ -131,105 +167,61 @@ class Resource(keystone_resource.ResourceDriverV8): return parents def is_leaf_project(self, project_id): - with sql.transaction() as session: + with sql.session_for_read() as session: project_refs = self._get_children(session, [project_id]) return not project_refs # CRUD @sql.handle_conflicts(conflict_type='project') - def create_project(self, tenant_id, tenant): - tenant['name'] = clean.project_name(tenant['name']) - with sql.transaction() as session: - tenant_ref = Project.from_dict(tenant) - session.add(tenant_ref) - return tenant_ref.to_dict() + def create_project(self, project_id, project): + project['name'] = clean.project_name(project['name']) + new_project = self._encode_domain_id(project) + with sql.session_for_write() as session: + project_ref = Project.from_dict(new_project) + session.add(project_ref) + return project_ref.to_dict() @sql.handle_conflicts(conflict_type='project') - def update_project(self, tenant_id, tenant): - if 'name' in tenant: - tenant['name'] = clean.project_name(tenant['name']) - - with sql.transaction() as session: - tenant_ref = self._get_project(session, tenant_id) - old_project_dict = tenant_ref.to_dict() - for k in tenant: - old_project_dict[k] = tenant[k] + def update_project(self, project_id, project): + if 'name' in project: + project['name'] = clean.project_name(project['name']) + + update_project = self._encode_domain_id(project) + with sql.session_for_write() as session: + project_ref = self._get_project(session, project_id) + old_project_dict = project_ref.to_dict() + for k in update_project: + old_project_dict[k] = update_project[k] + # When we read the old_project_dict, any "null" domain_id will have + # been decoded, so we need to re-encode it + old_project_dict = self._encode_domain_id(old_project_dict) new_project = Project.from_dict(old_project_dict) for attr in Project.attributes: if attr != 'id': - setattr(tenant_ref, attr, getattr(new_project, attr)) - tenant_ref.extra = new_project.extra - return tenant_ref.to_dict(include_extra_dict=True) + setattr(project_ref, attr, getattr(new_project, attr)) + project_ref.extra = new_project.extra + return project_ref.to_dict(include_extra_dict=True) @sql.handle_conflicts(conflict_type='project') - def delete_project(self, tenant_id): - with sql.transaction() as session: - tenant_ref = self._get_project(session, tenant_id) - session.delete(tenant_ref) - - # domain crud - - @sql.handle_conflicts(conflict_type='domain') - def create_domain(self, domain_id, domain): - with sql.transaction() as session: - ref = Domain.from_dict(domain) - session.add(ref) - return ref.to_dict() - - @sql.truncated - def list_domains(self, hints): - with sql.transaction() as session: - query = session.query(Domain) - refs = sql.filter_limit_query(Domain, query, hints) - return [ref.to_dict() for ref in refs] - - def list_domains_from_ids(self, ids): - if not ids: - return [] - else: - with sql.transaction() as session: - query = session.query(Domain) - query = query.filter(Domain.id.in_(ids)) - domain_refs = query.all() - return [domain_ref.to_dict() for domain_ref in domain_refs] - - def _get_domain(self, session, domain_id): - ref = session.query(Domain).get(domain_id) - if ref is None: - raise exception.DomainNotFound(domain_id=domain_id) - return ref - - def get_domain(self, domain_id): - with sql.transaction() as session: - return self._get_domain(session, domain_id).to_dict() - - def get_domain_by_name(self, domain_name): - with sql.transaction() as session: - try: - ref = (session.query(Domain). - filter_by(name=domain_name).one()) - except sql.NotFound: - raise exception.DomainNotFound(domain_id=domain_name) - return ref.to_dict() - - @sql.handle_conflicts(conflict_type='domain') - def update_domain(self, domain_id, domain): - with sql.transaction() as session: - ref = self._get_domain(session, domain_id) - old_dict = ref.to_dict() - for k in domain: - old_dict[k] = domain[k] - new_domain = Domain.from_dict(old_dict) - for attr in Domain.attributes: - if attr != 'id': - setattr(ref, attr, getattr(new_domain, attr)) - ref.extra = new_domain.extra - return ref.to_dict() + def delete_project(self, project_id): + with sql.session_for_write() as session: + project_ref = self._get_project(session, project_id) + session.delete(project_ref) - def delete_domain(self, domain_id): - with sql.transaction() as session: - ref = self._get_domain(session, domain_id) - session.delete(ref) + @sql.handle_conflicts(conflict_type='project') + def delete_projects_from_ids(self, project_ids): + if not project_ids: + return + with sql.session_for_write() as session: + query = session.query(Project).filter(Project.id.in_( + project_ids)) + project_ids_from_bd = [p['id'] for p in query.all()] + for project_id in project_ids: + if (project_id not in project_ids_from_bd or + project_id == keystone_resource.NULL_DOMAIN_ID): + LOG.warning(_LW('Project %s does not exist and was not ' + 'deleted.') % project_id) + query.delete(synchronize_session=False) class Domain(sql.ModelBase, sql.DictBase): @@ -239,22 +231,37 @@ class Domain(sql.ModelBase, sql.DictBase): name = sql.Column(sql.String(64), nullable=False) enabled = sql.Column(sql.Boolean, default=True, nullable=False) extra = sql.Column(sql.JsonBlob()) - __table_args__ = (sql.UniqueConstraint('name'), {}) + __table_args__ = (sql.UniqueConstraint('name'),) class Project(sql.ModelBase, sql.DictBase): + # NOTE(henry-nash): From the manager and above perspective, the domain_id + # is nullable. However, to ensure uniqueness in multi-process + # configurations, it is better to still use the sql uniqueness constraint. + # Since the support for a nullable component of a uniqueness constraint + # across different sql databases is mixed, we instead store a special value + # to represent null, as defined in NULL_DOMAIN_ID above. + + def to_dict(self, include_extra_dict=False): + d = super(Project, self).to_dict( + include_extra_dict=include_extra_dict) + if d['domain_id'] == keystone_resource.NULL_DOMAIN_ID: + d['domain_id'] = None + return d + __tablename__ = 'project' attributes = ['id', 'name', 'domain_id', 'description', 'enabled', 'parent_id', 'is_domain'] id = sql.Column(sql.String(64), primary_key=True) name = sql.Column(sql.String(64), nullable=False) - domain_id = sql.Column(sql.String(64), sql.ForeignKey('domain.id'), + domain_id = sql.Column(sql.String(64), sql.ForeignKey('project.id'), nullable=False) description = sql.Column(sql.Text()) enabled = sql.Column(sql.Boolean) extra = sql.Column(sql.JsonBlob()) parent_id = sql.Column(sql.String(64), sql.ForeignKey('project.id')) - is_domain = sql.Column(sql.Boolean, default=False, nullable=False) + is_domain = sql.Column(sql.Boolean, default=False, nullable=False, + server_default='0') # Unique constraint across two columns to create the separation # rather than just only 'name' being unique - __table_args__ = (sql.UniqueConstraint('domain_id', 'name'), {}) + __table_args__ = (sql.UniqueConstraint('domain_id', 'name'),) diff --git a/keystone-moon/keystone/resource/config_backends/sql.py b/keystone-moon/keystone/resource/config_backends/sql.py index 7c296074..6413becc 100644 --- a/keystone-moon/keystone/resource/config_backends/sql.py +++ b/keystone-moon/keystone/resource/config_backends/sql.py @@ -59,12 +59,12 @@ class DomainConfig(resource.DomainConfigDriverV8): @sql.handle_conflicts(conflict_type='domain_config') def create_config_option(self, domain_id, group, option, value, sensitive=False): - with sql.transaction() as session: + with sql.session_for_write() as session: config_table = self.choose_table(sensitive) ref = config_table(domain_id=domain_id, group=group, option=option, value=value) session.add(ref) - return ref.to_dict() + return ref.to_dict() def _get_config_option(self, session, domain_id, group, option, sensitive): try: @@ -80,14 +80,14 @@ class DomainConfig(resource.DomainConfigDriverV8): return ref def get_config_option(self, domain_id, group, option, sensitive=False): - with sql.transaction() as session: + with sql.session_for_read() as session: ref = self._get_config_option(session, domain_id, group, option, sensitive) - return ref.to_dict() + return ref.to_dict() def list_config_options(self, domain_id, group=None, option=None, sensitive=False): - with sql.transaction() as session: + with sql.session_for_read() as session: config_table = self.choose_table(sensitive) query = session.query(config_table) query = query.filter_by(domain_id=domain_id) @@ -99,11 +99,11 @@ class DomainConfig(resource.DomainConfigDriverV8): def update_config_option(self, domain_id, group, option, value, sensitive=False): - with sql.transaction() as session: + with sql.session_for_write() as session: ref = self._get_config_option(session, domain_id, group, option, sensitive) ref.value = value - return ref.to_dict() + return ref.to_dict() def delete_config_options(self, domain_id, group=None, option=None, sensitive=False): @@ -114,7 +114,7 @@ class DomainConfig(resource.DomainConfigDriverV8): if there was nothing to delete. """ - with sql.transaction() as session: + with sql.session_for_write() as session: config_table = self.choose_table(sensitive) query = session.query(config_table) query = query.filter_by(domain_id=domain_id) @@ -126,25 +126,25 @@ class DomainConfig(resource.DomainConfigDriverV8): def obtain_registration(self, domain_id, type): try: - with sql.transaction() as session: + with sql.session_for_write() as session: ref = ConfigRegister(type=type, domain_id=domain_id) session.add(ref) return True - except sql.DBDuplicateEntry: + except sql.DBDuplicateEntry: # nosec + # Continue on and return False to indicate failure. pass return False def read_registration(self, type): - with sql.transaction() as session: + with sql.session_for_read() as session: ref = session.query(ConfigRegister).get(type) if not ref: raise exception.ConfigRegistrationNotFound() - return ref.domain_id + return ref.domain_id def release_registration(self, domain_id, type=None): """Silently delete anything registered for the domain specified.""" - - with sql.transaction() as session: + with sql.session_for_write() as session: query = session.query(ConfigRegister) if type: query = query.filter_by(type=type) diff --git a/keystone-moon/keystone/resource/controllers.py b/keystone-moon/keystone/resource/controllers.py index 4fbeb715..5cabe064 100644 --- a/keystone-moon/keystone/resource/controllers.py +++ b/keystone-moon/keystone/resource/controllers.py @@ -18,7 +18,6 @@ import uuid from oslo_config import cfg -from oslo_log import log from keystone.common import controller from keystone.common import dependency @@ -31,7 +30,6 @@ from keystone.resource import schema CONF = cfg.CONF -LOG = log.getLogger(__name__) @dependency.requires('resource_api') @@ -40,13 +38,18 @@ class Tenant(controller.V2Controller): @controller.v2_deprecated def get_all_projects(self, context, **kw): """Gets a list of all tenants for an admin user.""" + self.assert_admin(context) + if 'name' in context['query_string']: - return self.get_project_by_name( - context, context['query_string'].get('name')) + return self._get_project_by_name(context['query_string']['name']) - self.assert_admin(context) - tenant_refs = self.resource_api.list_projects_in_domain( - CONF.identity.default_domain_id) + try: + tenant_refs = self.resource_api.list_projects_in_domain( + CONF.identity.default_domain_id) + except exception.DomainNotFound: + # If the default domain doesn't exist then there are no V2 + # projects. + tenant_refs = [] tenant_refs = [self.v3_to_v2_project(tenant_ref) for tenant_ref in tenant_refs if not tenant_ref.get('is_domain')] @@ -71,12 +74,11 @@ class Tenant(controller.V2Controller): self._assert_not_is_domain_project(tenant_id, ref) return {'tenant': self.v3_to_v2_project(ref)} - @controller.v2_deprecated - def get_project_by_name(self, context, tenant_name): - self.assert_admin(context) + def _get_project_by_name(self, tenant_name): # Projects acting as a domain should not be visible via v2 ref = self.resource_api.get_project_by_name( tenant_name, CONF.identity.default_domain_id) + self._assert_not_is_domain_project(ref['id'], ref) return {'tenant': self.v3_to_v2_project(ref)} # CRUD Extension @@ -88,7 +90,15 @@ class Tenant(controller.V2Controller): msg = _('Name field is required and cannot be empty') raise exception.ValidationError(message=msg) + if 'is_domain' in tenant_ref: + msg = _('The creation of projects acting as domains is not ' + 'allowed in v2.') + raise exception.ValidationError(message=msg) + self.assert_admin(context) + + self.resource_api.ensure_default_domain_exists() + tenant_ref['id'] = tenant_ref.get('id', uuid.uuid4().hex) initiator = notifications._get_request_audit_info(context) tenant = self.resource_api.create_project( @@ -162,11 +172,13 @@ class DomainV3(controller.V3Controller): @dependency.requires('domain_config_api') +@dependency.requires('resource_api') class DomainConfigV3(controller.V3Controller): member_name = 'config' @controller.protected() def create_domain_config(self, context, domain_id, config): + self.resource_api.get_domain(domain_id) original_config = ( self.domain_config_api.get_config_with_sensitive_info(domain_id)) ref = self.domain_config_api.create_config(domain_id, config) @@ -179,29 +191,39 @@ class DomainConfigV3(controller.V3Controller): @controller.protected() def get_domain_config(self, context, domain_id, group=None, option=None): + self.resource_api.get_domain(domain_id) ref = self.domain_config_api.get_config(domain_id, group, option) return {self.member_name: ref} @controller.protected() def update_domain_config( self, context, domain_id, config, group, option): + self.resource_api.get_domain(domain_id) ref = self.domain_config_api.update_config( domain_id, config, group, option) return wsgi.render_response(body={self.member_name: ref}) def update_domain_config_group(self, context, domain_id, group, config): + self.resource_api.get_domain(domain_id) return self.update_domain_config( context, domain_id, config, group, option=None) def update_domain_config_only(self, context, domain_id, config): + self.resource_api.get_domain(domain_id) return self.update_domain_config( context, domain_id, config, group=None, option=None) @controller.protected() def delete_domain_config( self, context, domain_id, group=None, option=None): + self.resource_api.get_domain(domain_id) self.domain_config_api.delete_config(domain_id, group, option) + @controller.protected() + def get_domain_config_default(self, context, group=None, option=None): + ref = self.domain_config_api.get_config_default(group, option) + return {self.member_name: ref} + @dependency.requires('resource_api') class ProjectV3(controller.V3Controller): @@ -216,25 +238,31 @@ class ProjectV3(controller.V3Controller): @validation.validated(schema.project_create, 'project') def create_project(self, context, project): ref = self._assign_unique_id(self._normalize_dict(project)) - ref = self._normalize_domain_id(context, ref) - if ref.get('is_domain'): - msg = _('The creation of projects acting as domains is not ' - 'allowed yet.') - raise exception.NotImplemented(msg) + if not ref.get('is_domain'): + ref = self._normalize_domain_id(context, ref) + # Our API requires that you specify the location in the hierarchy + # unambiguously. This could be by parent_id or, if it is a top level + # project, just by providing a domain_id. + if not ref.get('parent_id'): + ref['parent_id'] = ref.get('domain_id') initiator = notifications._get_request_audit_info(context) try: ref = self.resource_api.create_project(ref['id'], ref, initiator=initiator) - except exception.DomainNotFound as e: + except (exception.DomainNotFound, exception.ProjectNotFound) as e: raise exception.ValidationError(e) return ProjectV3.wrap_member(context, ref) @controller.filterprotected('domain_id', 'enabled', 'name', - 'parent_id') + 'parent_id', 'is_domain') def list_projects(self, context, filters): hints = ProjectV3.build_driver_hints(context, filters) + # If 'is_domain' has not been included as a query, we default it to + # False (which in query terms means '0' + if 'is_domain' not in context['query_string']: + hints.add_filter('is_domain', '0') refs = self.resource_api.list_projects(hints=hints) return ProjectV3.wrap_collection(context, refs, hints=hints) diff --git a/keystone-moon/keystone/resource/core.py b/keystone-moon/keystone/resource/core.py index 6891c572..f8d72e91 100644 --- a/keystone-moon/keystone/resource/core.py +++ b/keystone-moon/keystone/resource/core.py @@ -13,16 +13,20 @@ """Main entry point into the Resource service.""" import abc +import copy from oslo_config import cfg from oslo_log import log +from oslo_log import versionutils import six +from keystone import assignment from keystone.common import cache from keystone.common import clean from keystone.common import dependency from keystone.common import driver_hints from keystone.common import manager +from keystone.common import utils from keystone import exception from keystone.i18n import _, _LE, _LW from keystone import notifications @@ -30,18 +34,27 @@ from keystone import notifications CONF = cfg.CONF LOG = log.getLogger(__name__) -MEMOIZE = cache.get_memoization_decorator(section='resource') +MEMOIZE = cache.get_memoization_decorator(group='resource') def calc_default_domain(): return {'description': - (u'Owns users and tenants (i.e. projects)' - ' available on Identity API v2.'), + (u'The default domain'), 'enabled': True, 'id': CONF.identity.default_domain_id, 'name': u'Default'} +def _get_project_from_domain(domain_ref): + """Creates a project ref from the provided domain ref.""" + project_ref = domain_ref.copy() + project_ref['is_domain'] = True + project_ref['domain_id'] = None + project_ref['parent_id'] = None + + return project_ref + + @dependency.provider('resource_api') @dependency.requires('assignment_api', 'credential_api', 'domain_config_api', 'identity_api', 'revoke_api') @@ -69,48 +82,171 @@ class Manager(manager.Manager): super(Manager, self).__init__(resource_driver) + # Make sure it is a driver version we support, and if it is a legacy + # driver, then wrap it. + if isinstance(self.driver, ResourceDriverV8): + self.driver = V9ResourceWrapperForV8Driver(self.driver) + elif not isinstance(self.driver, ResourceDriverV9): + raise exception.UnsupportedDriverVersion(driver=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 + # NOTE(henry-nash): In upgrading to a scenario where domains are + # represented as projects acting as domains, we will effectively + # increase the depth of any existing project hierarchy by one. To avoid + # pushing any existing hierarchies over the limit, we add one to the + # maximum depth allowed, as specified in the configuration file. + max_depth = CONF.max_project_tree_depth + 1 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) - tenant.setdefault('is_domain', False) - - self.get_domain(tenant.get('domain_id')) - 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']) + raise exception.ForbiddenNotSecurity( + _('Max hierarchy depth reached for %s branch.') % project_id) + + def _assert_is_domain_project_constraints(self, project_ref): + """Enforces specific constraints of projects that act as domains + + Called when is_domain is true, this method ensures that: + + * multiple domains are enabled + * the project name is not the reserved name for a federated domain + * the project is a root project + + :raises keystone.exception.ValidationError: If one of the constraints + was not satisfied. + """ + if (not self.identity_api.multiple_domains_supported and + project_ref['id'] != CONF.identity.default_domain_id): + raise exception.ValidationError( + message=_('Multiple domains are not supported')) + + self.assert_domain_not_federated(project_ref['id'], project_ref) + + if project_ref['parent_id']: + raise exception.ValidationError( + message=_('only root projects are allowed to act as ' + 'domains.')) + + def _assert_regular_project_constraints(self, project_ref): + """Enforces regular project hierarchy constraints + + Called when is_domain is false. The project must contain a valid + domain_id and parent_id. The goal of this method is to check + that the domain_id specified is consistent with the domain of its + parent. + + :raises keystone.exception.ValidationError: If one of the constraints + was not satisfied. + :raises keystone.exception.DomainNotFound: In case the domain is not + found. + """ + # Ensure domain_id is valid, and by inference will not be None. + domain = self.get_domain(project_ref['domain_id']) + parent_ref = self.get_project(project_ref['parent_id']) + + if parent_ref['is_domain']: + if parent_ref['id'] != domain['id']: + raise exception.ValidationError( + message=_('Cannot create project, since its parent ' + '(%(domain_id)s) is acting as a domain, ' + 'but project\'s specified parent_id ' + '(%(parent_id)s) does not match ' + 'this domain_id.') + % {'domain_id': domain['id'], + 'parent_id': parent_ref['id']}) + else: + parent_domain_id = parent_ref.get('domain_id') + if parent_domain_id != domain['id']: + raise exception.ValidationError( + message=_('Cannot create project, since it specifies ' + 'its owner as domain %(domain_id)s, but ' + 'specifies a parent in a different domain ' + '(%(parent_domain_id)s).') + % {'domain_id': domain['id'], + 'parent_domain_id': parent_domain_id}) + + def _enforce_project_constraints(self, project_ref): + if project_ref.get('is_domain'): + self._assert_is_domain_project_constraints(project_ref) + else: + self._assert_regular_project_constraints(project_ref) + # The whole hierarchy (upwards) must be enabled + parent_id = project_ref['parent_id'] + parents_list = self.list_project_parents(parent_id) + parent_ref = self.get_project(parent_id) parents_list.append(parent_ref) for ref in parents_list: - if ref.get('domain_id') != tenant.get('domain_id'): - raise exception.ValidationError( - message=_('cannot create a project within a different ' - 'domain than its parents.')) if not ref.get('enabled', True): raise exception.ValidationError( message=_('cannot create a project in a ' 'branch containing a disabled ' 'project: %s') % ref['id']) - self._assert_max_hierarchy_depth(tenant.get('parent_id'), + + self._assert_max_hierarchy_depth(project_ref.get('parent_id'), parents_list) - ret = self.driver.create_project(tenant_id, tenant) - notifications.Audit.created(self._PROJECT, tenant_id, initiator) + def _raise_reserved_character_exception(self, entity_type, name): + msg = _('%(entity)s name cannot contain the following reserved ' + 'characters: %(chars)s') + raise exception.ValidationError( + message=msg % { + 'entity': entity_type, + 'chars': utils.list_url_unsafe_chars(name) + }) + + def _generate_project_name_conflict_msg(self, project): + if project['is_domain']: + return _('it is not permitted to have two projects ' + 'acting as domains with the same name: %s' + ) % project['name'] + else: + return _('it is not permitted to have two projects ' + 'within a domain with the same name : %s' + ) % project['name'] + + def create_project(self, project_id, project, initiator=None): + project = project.copy() + + if (CONF.resource.project_name_url_safe != 'off' and + utils.is_not_url_safe(project['name'])): + self._raise_reserved_character_exception('Project', + project['name']) + + project.setdefault('enabled', True) + project['enabled'] = clean.project_enabled(project['enabled']) + project.setdefault('description', '') + + # For regular projects, the controller will ensure we have a valid + # domain_id. For projects acting as a domain, the project_id + # is, effectively, the domain_id - and for such projects we don't + # bother to store a copy of it in the domain_id attribute. + project.setdefault('domain_id', None) + project.setdefault('parent_id', None) + if not project['parent_id']: + project['parent_id'] = project['domain_id'] + project.setdefault('is_domain', False) + + self._enforce_project_constraints(project) + + # We leave enforcing name uniqueness to the underlying driver (instead + # of doing it in code in the project_constraints above), so as to allow + # this check to be done at the storage level, avoiding race conditions + # in multi-process keystone configurations. + try: + ret = self.driver.create_project(project_id, project) + except exception.Conflict: + raise exception.Conflict( + type='project', + details=self._generate_project_name_conflict_msg(project)) + + if project.get('is_domain'): + notifications.Audit.created(self._DOMAIN, project_id, initiator) + else: + notifications.Audit.created(self._PROJECT, project_id, initiator) if MEMOIZE.should_cache(ret): - self.get_project.set(ret, self, tenant_id) + self.get_project.set(ret, self, project_id) self.get_project_by_name.set(ret, self, ret['name'], ret['domain_id']) return ret @@ -153,95 +289,257 @@ class Manager(manager.Manager): """ if project is None: project = self.get_project(project_id) - self.assert_domain_enabled(domain_id=project['domain_id']) + # If it's a regular project (i.e. it has a domain_id), we need to make + # sure the domain itself is not disabled + if project['domain_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.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 ('is_domain' in tenant and - tenant['is_domain'] != original_tenant['is_domain']): + raise exception.ForbiddenNotSecurity( + _('Cannot enable project %s since it has disabled ' + 'parents') % project_id) + + def _check_whole_subtree_is_disabled(self, project_id, subtree_list=None): + if not subtree_list: + subtree_list = self.list_projects_in_subtree(project_id) + subtree_enabled = [ref.get('enabled', True) for ref in subtree_list] + return (not any(subtree_enabled)) + + def _update_project(self, project_id, project, initiator=None, + cascade=False): + # Use the driver directly to prevent using old cached value. + original_project = self.driver.get_project(project_id) + project = project.copy() + + if original_project['is_domain']: + domain = self._get_domain_from_project(original_project) + self.assert_domain_not_federated(project_id, domain) + if 'enabled' in domain: + domain['enabled'] = clean.domain_enabled(domain['enabled']) + url_safe_option = CONF.resource.domain_name_url_safe + exception_entity = 'Domain' + else: + url_safe_option = CONF.resource.project_name_url_safe + exception_entity = 'Project' + + if (url_safe_option != 'off' and + 'name' in project and + project['name'] != original_project['name'] and + utils.is_not_url_safe(project['name'])): + self._raise_reserved_character_exception(exception_entity, + project['name']) + + parent_id = original_project.get('parent_id') + if 'parent_id' in project and project.get('parent_id') != parent_id: + raise exception.ForbiddenNotSecurity( + _('Update of `parent_id` is not allowed.')) + + if ('is_domain' in project and + project['is_domain'] != original_project['is_domain']): raise exception.ValidationError( message=_('Update of `is_domain` 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']) + update_domain = ('domain_id' in project and + project['domain_id'] != original_project['domain_id']) + + # NOTE(htruta): Even if we are allowing domain_ids to be + # modified (i.e. 'domain_id_immutable' is set False), + # a project.domain_id can only be updated for root projects + # that have no children. The update of domain_id of a project in + # the middle of the hierarchy creates an inconsistent project + # hierarchy. + if update_domain: + if original_project['is_domain']: + raise exception.ValidationError( + message=_('Update of domain_id of projects acting as ' + 'domains is not allowed.')) + parent_project = ( + self.driver.get_project(original_project['parent_id'])) + is_root_project = parent_project['is_domain'] + if not is_root_project: + raise exception.ValidationError( + message=_('Update of domain_id is only allowed for ' + 'root projects.')) + subtree_list = self.list_projects_in_subtree(project_id) + if subtree_list: + raise exception.ValidationError( + message=_('Cannot update domain_id of a project that ' + 'has children.')) + versionutils.report_deprecated_feature( + LOG, + _('update of domain_id is deprecated as of Mitaka ' + 'and will be removed in O.') + ) + + if 'enabled' in project: + project['enabled'] = clean.project_enabled(project['enabled']) + + original_project_enabled = original_project.get('enabled', True) + project_enabled = project.get('enabled', True) + if not original_project_enabled and project_enabled: + self._assert_all_parents_are_enabled(project_id) + if original_project_enabled and not project_enabled: + # NOTE(htruta): In order to disable a regular project, all its + # children must already be disabled. However, to keep + # compatibility with the existing domain behaviour, we allow a + # project acting as a domain to be disabled irrespective of the + # state of its children. Disabling a project acting as domain + # effectively disables its children. + if (not original_project.get('is_domain') and not cascade and not + self._check_whole_subtree_is_disabled(project_id)): + raise exception.ForbiddenNotSecurity( + _('Cannot disable project %(project_id)s since its ' + 'subtree contains enabled projects.') + % {'project_id': project_id}) + + notifications.Audit.disabled(self._PROJECT, project_id, + public=False) + if cascade: + self._only_allow_enabled_to_update_cascade(project, + original_project) + self._update_project_enabled_cascade(project_id, project_enabled) + + try: + project['is_domain'] = (project.get('is_domain') or + original_project['is_domain']) + ret = self.driver.update_project(project_id, project) + except exception.Conflict: + raise exception.Conflict( + type='project', + details=self._generate_project_name_conflict_msg(project)) + + notifications.Audit.updated(self._PROJECT, project_id, initiator) + if original_project['is_domain']: + notifications.Audit.updated(self._DOMAIN, project_id, initiator) + # If the domain is being disabled, issue the disable notification + # as well + if original_project_enabled and not project_enabled: + notifications.Audit.disabled(self._DOMAIN, project_id, + public=False) + + self.get_project.invalidate(self, project_id) + self.get_project_by_name.invalidate(self, original_project['name'], + original_project['domain_id']) + + if ('domain_id' in project and + project['domain_id'] != original_project['domain_id']): + # If the project's domain_id has been updated, invalidate user + # role assignments cache region, as it may be caching inherited + # assignments from the old domain to the specified project + assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate() + 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) + def _only_allow_enabled_to_update_cascade(self, project, original_project): + for attr in project: + if attr != 'enabled': + if project.get(attr) != original_project.get(attr): + raise exception.ValidationError( + message=_('Cascade update is only allowed for ' + 'enabled attribute.')) + + def _update_project_enabled_cascade(self, project_id, enabled): + subtree = self.list_projects_in_subtree(project_id) + # Update enabled only if different from original value + subtree_to_update = [child for child in subtree + if child['enabled'] != enabled] + for child in subtree_to_update: + child['enabled'] = enabled + + if not enabled: + # Does not in fact disable the project, only emits a + # notification that it was disabled. The actual disablement + # is done in the next line. + notifications.Audit.disabled(self._PROJECT, child['id'], + public=False) + + self.driver.update_project(child['id'], child) + + def update_project(self, project_id, project, initiator=None, + cascade=False): + ret = self._update_project(project_id, project, initiator, cascade) + if ret['is_domain']: + self.get_domain.invalidate(self, project_id) + self.get_domain_by_name.invalidate(self, ret['name']) - project = self.driver.get_project(tenant_id) + return ret + + def _pre_delete_cleanup_project(self, project_id, project, initiator=None): project_user_ids = ( - self.assignment_api.list_user_ids_for_project(tenant_id)) + self.assignment_api.list_user_ids_for_project(project_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) + payload = {'user_id': user_id, 'project_id': project_id} + notifications.Audit.internal( + notifications.INVALIDATE_USER_PROJECT_TOKEN_PERSISTENCE, + payload + ) + + def _post_delete_cleanup_project(self, project_id, project, + initiator=None): + self.assignment_api.delete_project_assignments(project_id) + self.get_project.invalidate(self, project_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) + self.credential_api.delete_credentials_for_project(project_id) + notifications.Audit.deleted(self._PROJECT, project_id, initiator) + # Invalidate user role assignments cache region, as it may + # be caching role assignments where the target is + # the specified project + assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate() + + def delete_project(self, project_id, initiator=None, cascade=False): + project = self.driver.get_project(project_id) + if project.get('is_domain'): + self.delete_domain(project_id, initiator) + else: + self._delete_project(project_id, initiator, cascade) + + def _delete_project(self, project_id, initiator=None, cascade=False): + # Use the driver directly to prevent using old cached value. + project = self.driver.get_project(project_id) + if project['is_domain'] and project['enabled']: + raise exception.ValidationError( + message=_('cannot delete an enabled project acting as a ' + 'domain. Please disable the project %s first.') + % project.get('id')) + + if not self.is_leaf_project(project_id) and not cascade: + raise exception.ForbiddenNotSecurity( + _('Cannot delete the project %s since it is not a leaf in the ' + 'hierarchy. Use the cascade option if you want to delete a ' + 'whole subtree.') + % project_id) + + if cascade: + # Getting reversed project's subtrees list, i.e. from the leaves + # to the root, so we do not break parent_id FK. + subtree_list = self.list_projects_in_subtree(project_id) + subtree_list.reverse() + if not self._check_whole_subtree_is_disabled( + project_id, subtree_list=subtree_list): + raise exception.ForbiddenNotSecurity( + _('Cannot delete project %(project_id)s since its subtree ' + 'contains enabled projects.') + % {'project_id': project_id}) + + project_list = subtree_list + [project] + projects_ids = [x['id'] for x in project_list] + + for prj in project_list: + self._pre_delete_cleanup_project(prj['id'], prj, initiator) + ret = self.driver.delete_projects_from_ids(projects_ids) + for prj in project_list: + self._post_delete_cleanup_project(prj['id'], prj, initiator) + else: + self._pre_delete_cleanup_project(project_id, project, initiator) + ret = self.driver.delete_project(project_id) + self._post_delete_cleanup_project(project_id, project, initiator) + return ret def _filter_projects_list(self, projects_list, user_id): @@ -378,85 +676,127 @@ class Manager(manager.Manager): project_id, _projects_indexed_by_parent(subtree_list)) return subtree_as_ids + 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. + + """ + # Retrieve the projects acting as domains get their correspondent + # domains + projects = self.list_projects_from_ids(domain_ids) + domains = [self._get_domain_from_project(project) + for project in projects] + + return domains + @MEMOIZE def get_domain(self, domain_id): - return self.driver.get_domain(domain_id) + try: + # Retrieve the corresponding project that acts as a domain + project = self.driver.get_project(domain_id) + except exception.ProjectNotFound: + raise exception.DomainNotFound(domain_id=domain_id) + + # Return its correspondent domain + return self._get_domain_from_project(project) @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) + try: + # Retrieve the corresponding project that acts as a domain + project = self.driver.get_project_by_name(domain_name, + domain_id=None) + except exception.ProjectNotFound: + raise exception.DomainNotFound(domain_id=domain_name) - notifications.Audit.created(self._DOMAIN, domain_id, initiator) + # Return its correspondent domain + return self._get_domain_from_project(project) - 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 + def _get_domain_from_project(self, project_ref): + """Creates a domain ref from a project ref. - @manager.response_truncated - def list_domains(self, hints=None): - return self.driver.list_domains(hints or driver_hints.Hints()) + Based on the provided project ref, create a domain ref, so that the + result can be returned in response to a domain API call. + """ + if not project_ref['is_domain']: + LOG.error(_LE('Asked to convert a non-domain project into a ' + 'domain - Domain: %(domain_id)s, Project ID: ' + '%(id)s, Project Name: %(project_name)s'), + {'domain_id': project_ref['domain_id'], + 'id': project_ref['id'], + 'project_name': project_ref['name']}) + raise exception.DomainNotFound(domain_id=project_ref['id']) + + domain_ref = project_ref.copy() + # As well as the project specific attributes that we need to remove, + # there is an old compatibility issue in that update project (as well + # as extracting an extra attributes), also includes a copy of the + # actual extra dict as well - something that update domain does not do. + for k in ['parent_id', 'domain_id', 'is_domain', 'extra']: + domain_ref.pop(k, None) + + return domain_ref - @notifications.disabled(_DOMAIN, public=False) - def _disable_domain(self, domain_id): - """Emit a notification to the callback system domain is been disabled. + def create_domain(self, domain_id, domain, initiator=None): + if (CONF.resource.domain_name_url_safe != 'off' and + utils.is_not_url_safe(domain['name'])): + self._raise_reserved_character_exception('Domain', domain['name']) + project_from_domain = _get_project_from_domain(domain) + is_domain_project = self.create_project( + domain_id, project_from_domain, initiator) - 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. + return self._get_domain_from_project(is_domain_project) - :param domain_id: domain identifier - :type domain_id: string - """ - pass + @manager.response_truncated + def list_domains(self, hints=None): + projects = self.list_projects_acting_as_domain(hints) + domains = [self._get_domain_from_project(project) + for project in projects] + return domains def update_domain(self, domain_id, domain, initiator=None): + # TODO(henry-nash): We shouldn't have to check for the federated domain + # here as well as _update_project, but currently our tests assume the + # checks are done in a specific order. The tests should be refactored. 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) + project = _get_project_from_domain(domain) + try: + original_domain = self.driver.get_project(domain_id) + project = self._update_project(domain_id, project, initiator) + except exception.ProjectNotFound: + raise exception.DomainNotFound(domain_id=domain_id) + domain_from_project = self._get_domain_from_project(project) 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')) + return domain_from_project - domain = self.driver.get_domain(domain_id) + def delete_domain(self, domain_id, initiator=None): + # Use the driver directly to get the project that acts as a domain and + # prevent using old cached value. + try: + domain = self.driver.get_project(domain_id) + except exception.ProjectNotFound: + raise exception.DomainNotFound(domain_id=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.')) + raise exception.ForbiddenNotSecurity( + _('Cannot delete a domain that is enabled, please disable it ' + 'first.')) self._delete_domain_contents(domain_id) + self._delete_project(domain_id, initiator) # 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) @@ -468,11 +808,14 @@ class Manager(manager.Manager): # 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']) + # Invalidate user role assignments cache region, as it may be caching + # role assignments where the target is the specified domain + assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate() + def _delete_domain_contents(self, domain_id): """Delete the contents of a domain. @@ -483,7 +826,6 @@ class Manager(manager.Manager): 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 ' @@ -498,7 +840,7 @@ class Manager(manager.Manager): _delete_projects(proj, projects, examined) try: - self.delete_project(project['id']) + self.delete_project(project['id'], initiator=None) except exception.ProjectNotFound: LOG.debug(('Project %(projectid)s not found when ' 'deleting domain contents for %(domainid)s, ' @@ -509,7 +851,7 @@ class Manager(manager.Manager): 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] + roots = [x for x in proj_refs if x.get('parent_id') == domain_id] examined = set() for project in roots: _delete_projects(project, proj_refs, examined) @@ -524,29 +866,258 @@ class Manager(manager.Manager): def list_projects_in_domain(self, domain_id): return self.driver.list_projects_in_domain(domain_id) + def list_projects_acting_as_domain(self, hints=None): + return self.driver.list_projects_acting_as_domain( + hints or driver_hints.Hints()) + @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) + def get_project_by_name(self, project_name, domain_id): + return self.driver.get_project_by_name(project_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 + def ensure_default_domain_exists(self): + """Creates the default domain if it doesn't exist. + + This is only used for the v2 API and can go away when V2 does. + + """ + try: + default_domain_attrs = { + 'name': 'Default', + 'id': CONF.identity.default_domain_id, + 'description': 'Domain created automatically to support V2.0 ' + 'operations.', + } + self.create_domain(CONF.identity.default_domain_id, + default_domain_attrs) + LOG.warning(_LW( + 'The default domain was created automatically to contain V2 ' + 'resources. This is deprecated in the M release and will not ' + 'be supported in the O release. Create the default domain ' + 'manually or use the keystone-manage bootstrap command.')) + except exception.Conflict: + LOG.debug('The default domain already exists.') + except Exception: + LOG.error(_LE('Failed to create the default domain.')) + raise + + +# The ResourceDriverBase class is the set of driver methods from earlier +# drivers that we still support, that have not been removed or modified. This +# class is then used to created the augmented V8 and V9 version abstract driver +# classes, without having to duplicate a lot of abstract method signatures. +# If you remove a method from V9, then move the abstract methods from this Base +# class to the V8 class. Do not modify any of the method signatures in the Base +# class - changes should only be made in the V8 and subsequent classes. + +# Starting with V9, some drivers use a special value to represent a domain_id +# of None. See comment in Project class of resource/backends/sql.py for more +# details. +NULL_DOMAIN_ID = '<>' @six.add_metaclass(abc.ABCMeta) -class ResourceDriverV8(object): +class ResourceDriverBase(object): def _get_list_limit(self): return CONF.resource.list_limit or CONF.list_limit + # project crud + @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: if project_id does not + exist + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_project(self, project_id, project): + """Updates an existing project. + + :raises keystone.exception.ProjectNotFound: if project_id does not + exist + :raises keystone.exception.Conflict: if project name already exists + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_project(self, project_id): + """Deletes an existing project. + + :raises keystone.exception.ProjectNotFound: if project_id does not + exist + + """ + 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: if project_id does not + exist + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def list_projects_in_subtree(self, project_id): + """List all projects in the subtree of a 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: if project_id does not + exist + + """ + 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: if project_id does not + exist + + """ + raise exception.NotImplemented() + + 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 belongs to the default domain.""" + if domain_id != CONF.identity.default_domain_id: + raise exception.DomainNotFound(domain_id=domain_id) + + +class ResourceDriverV8(ResourceDriverBase): + """Removed or redefined methods from V8. + + Move the abstract methods of any methods removed or modified in later + versions of the driver from ResourceDriverBase to here. We maintain this + so that legacy drivers, which will be a subclass of ResourceDriverV8, can + still reference them. + + """ + + @abc.abstractmethod + def create_project(self, tenant_id, tenant): + """Creates a new project. + + :param tenant_id: This parameter can be ignored. + :param dict tenant: The new project + + Project schema:: + + type: object + properties: + id: + type: string + name: + type: string + domain_id: + type: string + description: + type: string + enabled: + type: boolean + parent_id: + type: string + is_domain: + type: boolean + required: [id, name, domain_id] + additionalProperties: true + + If project doesn't match the schema the behavior is undefined. + + The driver can impose requirements such as the maximum length of a + field. If these requirements are not met the behavior is undefined. + + :raises keystone.exception.Conflict: if the project id already exists + or the name already exists for the domain_id. + + """ + raise exception.NotImplemented() # pragma: no cover + @abc.abstractmethod def get_project_by_name(self, tenant_name, domain_id): """Get a tenant by name. @@ -558,6 +1129,21 @@ class ResourceDriverV8(object): """ raise exception.NotImplemented() # pragma: no cover + # Domain management functions for backends that only allow a single + # domain. Although we no longer use this, a custom legacy driver might + # have made use of it, so keep it here in case. + 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)) + # domain crud @abc.abstractmethod def create_domain(self, domain_id, domain): @@ -635,182 +1221,288 @@ class ResourceDriverV8(object): """ 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: if project_id or project name - already exists +class ResourceDriverV9(ResourceDriverBase): + """New or redefined methods from V8. - """ - raise exception.NotImplemented() # pragma: no cover + Add any new V9 abstract methods (or those with modified signatures) to + this class. - @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. + @abc.abstractmethod + def create_project(self, project_id, project): + """Creates a new project. - :returns: a list of project_refs or an empty list. + :param project_id: This parameter can be ignored. + :param dict project: The new project + + Project schema:: + + type: object + properties: + id: + type: string + name: + type: string + domain_id: + type: [string, null] + description: + type: string + enabled: + type: boolean + parent_id: + type: string + is_domain: + type: boolean + required: [id, name, domain_id] + additionalProperties: true + + If the project doesn't match the schema the behavior is undefined. + + The driver can impose requirements such as the maximum length of a + field. If these requirements are not met the behavior is undefined. + + :raises keystone.exception.Conflict: if the project id already exists + or the name already exists for the domain_id. """ 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. + def get_project_by_name(self, project_name, domain_id): + """Get a project by name. - This method is used internally by the assignment manager to bulk read - a set of projects given their ids. + :returns: project_ref + :raises keystone.exception.ProjectNotFound: if a project with the + project_name does not exist within the domain """ 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. - + def delete_projects_from_ids(self, project_ids): + """Deletes a given list of projects. + + Deletes a list of projects. Ensures no project on the list exists + after it is successfully called. If an empty list is provided, + the it is silently ignored. In addition, if a project ID in the list + of project_ids is not found in the backend, no exception is raised, + but a message is logged. """ raise exception.NotImplemented() # pragma: no cover @abc.abstractmethod - def list_projects_in_domain(self, domain_id): - """List projects in the domain. + def list_projects_acting_as_domain(self, hints): + """List all projects acting as domains. - :param domain_id: the driver MUST only return projects - within this domain. + :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 get_project(self, project_id): - """Get a project by ID. - :returns: project_ref - :raises keystone.exception.ProjectNotFound: if project_id does not - exist +class V9ResourceWrapperForV8Driver(ResourceDriverV9): + """Wrapper class to supported a V8 legacy driver. - """ - raise exception.NotImplemented() # pragma: no cover + In order to support legacy drivers without having to make the manager code + driver-version aware, we wrap legacy drivers so that they look like the + latest version. For the various changes made in a new driver, here are the + actions needed in this wrapper: - @abc.abstractmethod - def update_project(self, project_id, project): - """Updates an existing project. + Method removed from new driver - remove the call-through method from this + class, since the manager will no longer be + calling it. + Method signature (or meaning) changed - wrap the old method in a new + signature here, and munge the input + and output parameters accordingly. + New method added to new driver - add a method to implement the new + functionality here if possible. If that is + not possible, then return NotImplemented, + since we do not guarantee to support new + functionality with legacy drivers. - :raises keystone.exception.ProjectNotFound: if project_id does not - exist - :raises keystone.exception.Conflict: if project name already exists + This wrapper contains the following support for newer manager code: - """ - raise exception.NotImplemented() # pragma: no cover + - The current manager code expects domains to be represented as projects + acting as domains, something that may not be possible in a legacy driver. + Hence the wrapper will map any calls for projects acting as a domain back + onto the driver domain methods. The caveat for this, is that this assumes + that there can not be a clash between a project_id and a domain_id, in + which case it may not be able to locate the correct entry. - @abc.abstractmethod - def delete_project(self, project_id): - """Deletes an existing project. + """ - :raises keystone.exception.ProjectNotFound: if project_id does not - exist + @versionutils.deprecated( + as_of=versionutils.deprecated.MITAKA, + what='keystone.resource.ResourceDriverV8', + in_favor_of='keystone.resource.ResourceDriverV9', + remove_in=+2) + def __init__(self, wrapped_driver): + self.driver = wrapped_driver + def _get_domain_from_project(self, project_ref): + """Creates a domain ref from a project ref. + + Based on the provided project ref (or partial ref), creates a + domain ref, so that the result can be passed to the driver + domain methods. """ - raise exception.NotImplemented() # pragma: no cover + domain_ref = project_ref.copy() + for k in ['parent_id', 'domain_id', 'is_domain']: + domain_ref.pop(k, None) + return domain_ref - @abc.abstractmethod - def list_project_parents(self, project_id): - """List all parents from a project by its ID. + def get_project_by_name(self, project_name, domain_id): + if domain_id is None: + try: + domain_ref = self.driver.get_domain_by_name(project_name) + return _get_project_from_domain(domain_ref) + except exception.DomainNotFound: + raise exception.ProjectNotFound(project_id=project_name) + else: + return self.driver.get_project_by_name(project_name, domain_id) - :param project_id: the driver will list the parents of this - project. + def create_project(self, project_id, project): + if project['is_domain']: + new_domain = self._get_domain_from_project(project) + domain_ref = self.driver.create_domain(project_id, new_domain) + return _get_project_from_domain(domain_ref) + else: + return self.driver.create_project(project_id, project) - :returns: a list of project_refs or an empty list. - :raises keystone.exception.ProjectNotFound: if project_id does not - exist + def list_projects(self, hints): + """List projects and/or domains. - """ - raise exception.NotImplemented() + We use the hints filter to determine whether we are listing projects, + domains or both. - @abc.abstractmethod - def list_projects_in_subtree(self, project_id): - """List all projects in the subtree below the hierarchy of the - given project. + If the filter includes domain_id==None, then we should only list + domains (convert to a project acting as a domain) since regular + projcets always have a non-None value for domain_id. - :param project_id: the driver will get the subtree under - this project. + Likewise, if the filter includes domain_id==, then we + should only list projects. - :returns: a list of project_refs or an empty list - :raises keystone.exception.ProjectNotFound: if project_id does not - exist + If there is no domain_id filter, then we need to do a combained listing + of domains and projects, converting domains to projects acting as a + domain. """ - raise exception.NotImplemented() + domain_listing_filter = None + for f in hints.filters: + if (f['name'] == 'domain_id'): + domain_listing_filter = f + + if domain_listing_filter is not None: + if domain_listing_filter['value'] is not None: + proj_list = self.driver.list_projects(hints) + else: + domains = self.driver.list_domains(hints) + proj_list = [_get_project_from_domain(p) for p in domains] + hints.filters.remove(domain_listing_filter) + return proj_list + else: + # No domain_id filter, so combine domains and projects. Although + # we hand any remaining filters into each driver, since each filter + # might need to be carried out more than once, we use copies of the + # filters, allowing the original filters to be passed back up to + # controller level where a final filter will occur. + local_hints = copy.deepcopy(hints) + proj_list = self.driver.list_projects(local_hints) + local_hints = copy.deepcopy(hints) + domains = self.driver.list_domains(local_hints) + for domain in domains: + proj_list.append(_get_project_from_domain(domain)) + return proj_list - @abc.abstractmethod - def is_leaf_project(self, project_id): - """Checks if a project is a leaf in the hierarchy. + def list_projects_from_ids(self, project_ids): + return [self.get_project(id) for id in project_ids] - :param project_id: the driver will check if this project - is a leaf in the hierarchy. + def list_project_ids_from_domain_ids(self, domain_ids): + return self.driver.list_project_ids_from_domain_ids(domain_ids) - :raises keystone.exception.ProjectNotFound: if project_id does not - exist + def list_projects_in_domain(self, domain_id): + return self.driver.list_projects_in_domain(domain_id) - """ - raise exception.NotImplemented() + def get_project(self, project_id): + try: + domain_ref = self.driver.get_domain(project_id) + return _get_project_from_domain(domain_ref) + except exception.DomainNotFound: + return self.driver.get_project(project_id) - # 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] + def _is_domain(self, project_id): + ref = self.get_project(project_id) + return ref.get('is_domain', False) + + def update_project(self, project_id, project): + if self._is_domain(project_id): + update_domain = self._get_domain_from_project(project) + domain_ref = self.driver.update_domain(project_id, update_domain) + return _get_project_from_domain(domain_ref) else: - raise ValueError(_('Expected dict or list: %s') % type(ref)) + return self.driver.update_project(project_id, project) - def _validate_default_domain(self, ref): - """Validate that either the default domain or nothing is specified. + def delete_project(self, project_id): + if self._is_domain(project_id): + try: + self.driver.delete_domain(project_id) + except exception.DomainNotFound: + raise exception.ProjectNotFound(project_id=project_id) + else: + self.driver.delete_project(project_id) - Also removes the domain from the ref so that LDAP doesn't have to - persist the attribute. + def delete_projects_from_ids(self, project_ids): + raise exception.NotImplemented() # pragma: no cover - """ - ref = ref.copy() - domain_id = ref.pop('domain_id', CONF.identity.default_domain_id) - self._validate_default_domain_id(domain_id) - return ref + def list_project_parents(self, project_id): + """List a project's ancestors. - def _validate_default_domain_id(self, domain_id): - """Validate that the domain ID specified belongs to the default domain. + The current manager expects the ancestor tree to end with the project + acting as the domain (since that's now the top of the tree), but a + legacy driver will not have that top project in their projects table, + since it's still in the domain table. Hence we lift the algorithm for + traversing up the tree from the driver to here, so that our version of + get_project() is called, which will fetch the "project" from the right + table. """ - if domain_id != CONF.identity.default_domain_id: - raise exception.DomainNotFound(domain_id=domain_id) + project = self.get_project(project_id) + parents = [] + examined = set() + while project.get('parent_id') is not None: + if project['id'] in examined: + msg = _LE('Circular reference or a repeated ' + 'entry found in projects hierarchy - ' + '%(project_id)s.') + LOG.error(msg, {'project_id': project['id']}) + return + + examined.add(project['id']) + parent_project = self.get_project(project['parent_id']) + parents.append(parent_project) + project = parent_project + return parents + + def list_projects_in_subtree(self, project_id): + return self.driver.list_projects_in_subtree(project_id) + + def is_leaf_project(self, project_id): + return self.driver.is_leaf_project(project_id) + + def list_projects_acting_as_domain(self, hints): + refs = self.driver.list_domains(hints) + return [_get_project_from_domain(p) for p in refs] Driver = manager.create_legacy_driver(ResourceDriverV8) -MEMOIZE_CONFIG = cache.get_memoization_decorator(section='domain_config') +MEMOIZE_CONFIG = cache.get_memoization_decorator(group='domain_config') @dependency.provider('domain_config_api') @@ -829,15 +1521,16 @@ class DomainConfigManager(manager.Manager): driver_namespace = 'keystone.resource.domain_config' whitelisted_options = { - 'identity': ['driver'], + 'identity': ['driver', 'list_limit'], '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_description_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', @@ -928,7 +1621,6 @@ class DomainConfigManager(manager.Manager): def _config_to_list(self, config): """Build whitelisted and sensitive lists for use by backend drivers.""" - whitelisted = [] sensitive = [] for group in config: @@ -1086,7 +1778,6 @@ class DomainConfigManager(manager.Manager): """ 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) @@ -1145,7 +1836,6 @@ class DomainConfigManager(manager.Manager): 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'], @@ -1266,7 +1956,7 @@ class DomainConfigManager(manager.Manager): 'value: %(value)s.') if warning_msg: - LOG.warn(warning_msg % { + LOG.warning(warning_msg % { 'domain': domain_id, 'group': each_whitelisted['group'], 'option': each_whitelisted['option'], @@ -1285,6 +1975,59 @@ class DomainConfigManager(manager.Manager): """ return self._get_config_with_sensitive_info(domain_id) + def get_config_default(self, group=None, option=None): + """Get default config, or partial default config + + :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 default options, + filtered by group and option if specified + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support (or one that is not whitelisted). + + An example response:: + + { + 'ldap': { + 'url': 'myurl', + 'user_tree_dn': 'OU=myou', + ....}, + 'identity': { + 'driver': 'ldap'} + + } + + """ + def _option_dict(group, option): + group_attr = getattr(CONF, group) + if group_attr is None: + msg = _('Group %s not found in config') % group + raise exception.UnexpectedError(msg) + return {'group': group, 'option': option, + 'value': getattr(group_attr, option)} + + self._assert_valid_group_and_option(group, option) + config_list = [] + if group: + if option: + if option not in self.whitelisted_options[group]: + msg = _('Reading the default for option %(option)s in ' + 'group %(group)s is not supported') % { + 'option': option, 'group': group} + raise exception.InvalidDomainConfig(reason=msg) + config_list.append(_option_dict(group, option)) + else: + for each_option in self.whitelisted_options[group]: + config_list.append(_option_dict(group, each_option)) + else: + for each_group in self.whitelisted_options: + for each_option in self.whitelisted_options[each_group]: + config_list.append(_option_dict(each_group, each_option)) + + return self._list_to_config(config_list, req_option=option) + @six.add_metaclass(abc.ABCMeta) class DomainConfigDriverV8(object): @@ -1394,8 +2137,8 @@ class DomainConfigDriverV8(object): :param type: type of registration :returns: domain_id of who is registered. - :raises: keystone.exception.ConfigRegistrationNotFound: nobody is - registered. + :raises keystone.exception.ConfigRegistrationNotFound: If nobody is + registered. """ raise exception.NotImplemented() # pragma: no cover diff --git a/keystone-moon/keystone/resource/routers.py b/keystone-moon/keystone/resource/routers.py index 8ccd10aa..d58474e2 100644 --- a/keystone-moon/keystone/resource/routers.py +++ b/keystone-moon/keystone/resource/routers.py @@ -88,6 +88,37 @@ class Routers(wsgi.RoutersBase): 'config_option') }) + self._add_resource( + mapper, config_controller, + path='/domains/config/default', + get_action='get_domain_config_default', + rel=json_home.build_v3_resource_relation('domain_config_default'), + status=json_home.Status.EXPERIMENTAL) + + self._add_resource( + mapper, config_controller, + path='/domains/config/{group}/default', + get_action='get_domain_config_default', + rel=json_home.build_v3_resource_relation( + 'domain_config_default_group'), + status=json_home.Status.EXPERIMENTAL, + path_vars={ + 'group': config_group_param + }) + + self._add_resource( + mapper, config_controller, + path='/domains/config/{group}/{option}/default', + get_action='get_domain_config_default', + rel=json_home.build_v3_resource_relation( + 'domain_config_default_option'), + status=json_home.Status.EXPERIMENTAL, + path_vars={ + 'group': config_group_param, + 'option': json_home.build_v3_parameter_relation( + 'config_option') + }) + routers.append( router.Router(controllers.ProjectV3(), 'projects', 'project', diff --git a/keystone-moon/keystone/resource/schema.py b/keystone-moon/keystone/resource/schema.py index e26a9c4a..7e2cd667 100644 --- a/keystone-moon/keystone/resource/schema.py +++ b/keystone-moon/keystone/resource/schema.py @@ -16,10 +16,8 @@ from keystone.common.validation import parameter_types _project_properties = { 'description': validation.nullable(parameter_types.description), - # NOTE(lbragstad): domain_id isn't nullable according to some backends. - # The identity-api should be updated to be consistent with the - # implementation. - 'domain_id': parameter_types.id_string, + # NOTE(htruta): domain_id is nullable for projects acting as a domain. + 'domain_id': validation.nullable(parameter_types.id_string), 'enabled': parameter_types.boolean, 'is_domain': parameter_types.boolean, 'parent_id': validation.nullable(parameter_types.id_string), @@ -43,7 +41,7 @@ project_create = { project_update = { 'type': 'object', 'properties': _project_properties, - # NOTE(lbragstad) Make sure at least one property is being updated + # NOTE(lbragstad): Make sure at least one property is being updated 'minProperties': 1, 'additionalProperties': True } -- cgit 1.2.3-korg