diff --git a/doc/source/architecture.rst b/doc/source/architecture.rst index 6026cc1367..dec685f022 100644 --- a/doc/source/architecture.rst +++ b/doc/source/architecture.rst @@ -139,6 +139,7 @@ implementations. The drivers for the services are: * :mod:`keystone.catalog.core.Driver` * :mod:`keystone.identity.core.Driver` * :mod:`keystone.policy.core.Driver` +* :mod:`keystone.resource.core.Driver` * :mod:`keystone.token.core.Driver` If you implement a backend driver for one of the Keystone services, you're diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 671b0ec155..cd67ad02a4 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -108,6 +108,7 @@ configuration file is organized into the following sections: * ``[os_inherit]`` - Inherited role assignment extension * ``[paste_deploy]`` - Pointer to the PasteDeploy configuration file * ``[policy]`` - Policy system driver configuration for RBAC +* ``[resource]`` - Resource system driver configuration * ``[revoke]`` - Revocation system driver configuration * ``[role]`` - Role system driver configuration * ``[saml]`` - SAML configuration options @@ -499,29 +500,29 @@ Current Keystone systems that have caching capabilities: revocation list is refreshed whenever a token is revoked. It typically sees significantly more requests than specific token retrievals or token validation calls. - * ``assignment`` - The assignment system has a separate ``cache_time`` configuration - option, that can be set to a value above or below the global + * ``resource`` + The resource system has a separate ``cache_time`` configuration option, + that can be set to a value above or below the global ``expiration_time`` default, allowing for different caching behavior from the other systems in ``Keystone``. This option is set in the - ``[assignment]`` section of the configuration file. + ``[resource]`` section of the configuration file. - Currently ``assignment`` has caching for ``project`` and ``domain`` - specific requests (primarily around the CRUD actions). Caching is - currently not implemented on grants. The list (``list_projects``, - ``list_domains``, etc) methods are not subject to caching. + Currently ``resource`` has caching for ``project`` and ``domain`` + specific requests (primarily around the CRUD actions). The + ``list_projects`` and ``list_domains`` methods are not subject to + caching. .. WARNING:: - Be aware that if a read-only ``assignment`` backend is in use, the - cache will not immediately reflect changes on the backend. Any + Be aware that if a read-only ``resource`` backend is in use, the + cache will not immediately reflect changes on the back end. Any given change may take up to the ``cache_time`` (if set in the - ``[assignment]`` section of the configuration) or the global + ``[resource]`` section of the configuration) or the global ``expiration_time`` (set in the ``[cache]`` section of the configuration) before it is reflected. If this type of delay (when - using a read-only ``assignment`` backend) is an issue, it is - recommended that caching be disabled on ``assignment``. To disable - caching specifically on ``assignment``, in the ``[assignment]`` - section of the configuration set ``caching`` to ``False``. + using a read-only ``resource`` backend) is an issue, it is + recommended that caching be disabled on ``resource``. To disable + caching specifically on ``resource``, in the ``[resource]`` section + of the configuration set ``caching`` to ``False``. * ``role`` Currently ``role`` has caching for ``get_role``, but not for ``list_roles``. The role system has a separate ``cache_time`` configuration option, @@ -952,7 +953,7 @@ override this global value with a specific limit, for example: .. code-block:: ini - [assignment] + [resource] list_limit = 100 If a response to ``list_{entity}`` call has been truncated, then the response @@ -1527,15 +1528,18 @@ directories in conjunction with reading user and group information. Keystone now provides an option whereby these read-only directories can be easily integrated as it now enables its identity entities (which comprises users, groups, and group memberships) to be served out of directories while -assignments (which comprises projects, role assignments, and domains) and roles -are to be served from different Keystone backends (i.e. SQL). To enable this -option, you must have the following ``keystone.conf`` options set: +resource (which comprises projects and domains), assignment and role +entities are to be served from different Keystone backends (i.e. SQL). To +enable this option, you must have the following ``keystone.conf`` options set: .. code-block:: ini [identity] driver = keystone.identity.backends.ldap.Identity + [resource] + driver = keystone.resource.backends.sql.Resource + [assignment] driver = keystone.assignment.backends.sql.Assignment @@ -1544,15 +1548,16 @@ option, you must have the following ``keystone.conf`` options set: With the above configuration, Keystone will only lookup identity related information such users, groups, and group membership from the directory, while -assignment related information will be provided by the SQL backend. Also note -that if there is an LDAP Identity, and no assignment or role backend is -specified, they will default to LDAP. Although this may seem counterintuitive, -it is provided for backwards compatibility. Nonetheless, the explicit option -will always override the implicit option, so specifying the options as shown -above will always be correct. Finally, it is also worth noting that whether or -not the LDAP accessible directory is to be considered read only is still -configured as described in a previous section above by setting values such as -the following in the ``[ldap]`` configuration section: +resources, roles and assignment related information will be provided by the SQL +backend. Also note that if there is an LDAP Identity, and no resource, +assignment or role backend is specified, they will default to LDAP. Although +this may seem counterintuitive, it is provided for backwards compatibility. +Nonetheless, the explicit option will always override the implicit option, so +specifying the options as shown above will always be correct. Finally, it is +also worth noting that whether or not the LDAP accessible directory is to be +considered read only is still configured as described in a previous section +above by setting values such as the following in the ``[ldap]`` configuration +section: .. code-block:: ini diff --git a/etc/keystone.conf.sample b/etc/keystone.conf.sample index 9cbb005cfc..2c556ef80c 100644 --- a/etc/keystone.conf.sample +++ b/etc/keystone.conf.sample @@ -295,9 +295,9 @@ # able to take everything down, as it requires a clean break. (integer value) #qpid_topology_version = 1 -# SSL version to use (valid only if SSL enabled). valid values are TLSv1 and -# SSLv23. SSLv2 and SSLv3 may be available on some distributions. (string -# value) +# SSL version to use (valid only if SSL enabled). Valid values are TLSv1 and +# SSLv23. SSLv2, SSLv3, TLSv1_1, and TLSv1_2 may be available on some +# distributions. (string value) #kombu_ssl_version = # SSL key file (valid only if SSL enabled). (string value) @@ -360,7 +360,7 @@ #rpc_zmq_bind_address = * # MatchMaker driver. (string value) -#rpc_zmq_matchmaker = oslo.messaging._drivers.matchmaker.MatchMakerLocalhost +#rpc_zmq_matchmaker = oslo_messaging._drivers.matchmaker.MatchMakerLocalhost # ZeroMQ receiver listening port. (integer value) #rpc_zmq_port = 9501 @@ -389,7 +389,7 @@ # Heartbeat time-to-live. (integer value) #matchmaker_heartbeat_ttl = 600 -# Size of RPC greenthread pool. (integer value) +# Size of RPC thread pool. (integer value) #rpc_thread_pool_size = 64 # Driver or drivers to handle sending notifications. (multi valued) @@ -425,18 +425,6 @@ # Assignment backend driver. (string value) #driver = -# Toggle for assignment caching. This has no effect unless global caching is -# enabled. (boolean value) -#caching = true - -# TTL (in seconds) to cache assignment data. This has no effect unless global -# caching is enabled. (integer value) -#cache_time = - -# Maximum number of entities that will be returned in an assignment collection. -# (integer value) -#list_limit = - [auth] @@ -1270,6 +1258,32 @@ #list_limit = +[resource] + +# +# From keystone +# + +# Resource backend driver. If a resource driver is not specified, the +# assignment driver will choose the resource driver. (string value) +#driver = + +# Toggle for resource caching. This has no effect unless global caching is +# enabled. (boolean value) +# Deprecated group/name - [assignment]/caching +#caching = true + +# TTL (in seconds) to cache resource data. This has no effect unless global +# caching is enabled. (integer value) +# Deprecated group/name - [assignment]/cache_time +#cache_time = + +# Maximum number of entities that will be returned in a resource collection. +# (integer value) +# Deprecated group/name - [assignment]/list_limit +#list_limit = + + [revoke] # diff --git a/keystone/assignment/backends/ldap.py b/keystone/assignment/backends/ldap.py index a8b9ff41b5..d491a86005 100644 --- a/keystone/assignment/backends/ldap.py +++ b/keystone/assignment/backends/ldap.py @@ -13,15 +13,11 @@ # under the License. from __future__ import absolute_import -import uuid - import ldap as ldap import ldap.filter from keystone import assignment from keystone.assignment.role_backends import ldap as ldap_role -from keystone import clean -from keystone.common import driver_hints from keystone.common import ldap as common_ldap from keystone.common import models from keystone import config @@ -55,91 +51,8 @@ class Assignment(assignment.Driver): def default_role_driver(self): return 'keystone.assignment.role_backends.ldap.Role' - def _set_default_parent_project(self, ref): - """If the parent project ID has not been set, set it to None.""" - if isinstance(ref, dict): - if 'parent_id' not in ref: - ref = dict(ref, parent_id=None) - return ref - elif isinstance(ref, list): - return [self._set_default_parent_project(x) for x in ref] - else: - raise ValueError(_('Expected dict or list: %s') % type(ref)) - - def _validate_parent_project_is_none(self, ref): - """If a parent_id different from None was given, - raises InvalidProjectException. - - """ - parent_id = ref.get('parent_id') - if parent_id is not None: - raise exception.InvalidParentProject(parent_id) - - def _set_default_attributes(self, project_ref): - project_ref = self._set_default_domain(project_ref) - return self._set_default_parent_project(project_ref) - - def get_project(self, tenant_id): - return self._set_default_attributes( - self.project.get(tenant_id)) - - def list_projects(self, hints): - return self._set_default_attributes( - self.project.get_all()) - - def list_projects_in_domain(self, domain_id): - # We don't support multiple domains within this driver, so ignore - # any domain specified - return self.list_projects(driver_hints.Hints()) - - def list_projects_in_subtree(self, project_id): - # We don't support projects hierarchy within this driver, so a - # project will never have children - return [] - - def list_project_parents(self, project_id): - # We don't support projects hierarchy within this driver, so a - # project will never have parents - return [] - - def is_leaf_project(self, project_id): - # We don't support projects hierarchy within this driver, so a - # project will always be a root and a leaf at the same time - return True - - def list_projects_from_ids(self, ids): - return [self.get_project(id) for id in ids] - - def list_project_ids_from_domain_ids(self, domain_ids): - # We don't support multiple domains within this driver, so ignore - # any domain specified. - return [x.id for x in self.list_projects(driver_hints.Hints())] - - def get_project_by_name(self, tenant_name, domain_id): - self._validate_default_domain_id(domain_id) - return self._set_default_attributes( - self.project.get_by_name(tenant_name)) - - def create_project(self, tenant_id, tenant): - self.project.check_allow_create() - tenant = self._validate_default_domain(tenant) - self._validate_parent_project_is_none(tenant) - tenant['name'] = clean.project_name(tenant['name']) - data = tenant.copy() - if 'id' not in data or data['id'] is None: - data['id'] = str(uuid.uuid4().hex) - if 'description' in data and data['description'] in ['', None]: - data.pop('description') - return self._set_default_attributes( - self.project.create(data)) - - def update_project(self, tenant_id, tenant): - self.project.check_allow_update() - tenant = self._validate_default_domain(tenant) - if 'name' in tenant: - tenant['name'] = clean.project_name(tenant['name']) - return self._set_default_attributes( - self.project.update(tenant_id, tenant)) + def default_resource_driver(self): + return 'keystone.resource.backends.ldap.Resource' def list_role_ids_for_groups_on_project( self, groups, project_id, project_domain_id, project_parents): @@ -251,15 +164,6 @@ class Assignment(assignment.Driver): role_dn=role_dn, tenant_dn=tenant_dn) - def delete_project(self, tenant_id): - self.project.check_allow_delete() - if self.project.subtree_delete_enabled: - self.project.deleteTree(tenant_id) - else: - # The manager layer will call assignments to delete the - # role assignments, so we just have to delete the project itself. - self.project.delete(tenant_id) - def remove_role_from_user_and_project(self, user_id, tenant_id, role_id): role_dn = self._subrole_id_to_dn(role_id, tenant_id) return self.role.delete_user(role_dn, @@ -271,30 +175,6 @@ class Assignment(assignment.Driver): return self.role.delete_user(role_dn, self.group._id_to_dn(group_id), role_id) - def create_domain(self, domain_id, domain): - if domain_id == CONF.identity.default_domain_id: - msg = _('Duplicate ID, %s.') % domain_id - raise exception.Conflict(type='domain', details=msg) - raise exception.Forbidden(_('Domains are read-only against LDAP')) - - def get_domain(self, domain_id): - self._validate_default_domain_id(domain_id) - return assignment.calc_default_domain() - - def update_domain(self, domain_id, domain): - self._validate_default_domain_id(domain_id) - raise exception.Forbidden(_('Domains are read-only against LDAP')) - - def delete_domain(self, domain_id): - self._validate_default_domain_id(domain_id) - raise exception.Forbidden(_('Domains are read-only against LDAP')) - - def list_domains(self, hints): - return [assignment.calc_default_domain()] - - def list_domains_from_ids(self, ids): - return [assignment.calc_default_domain()] - # Bulk actions on User From identity def delete_user(self, user_id): user_dn = self.user._id_to_dn(user_id) @@ -384,12 +264,6 @@ class Assignment(assignment.Driver): return self._roles_from_role_dicts(metadata_ref.get('roles', []), inherited_to_projects) - def get_domain_by_name(self, domain_name): - default_domain = assignment.calc_default_domain() - if domain_name != default_domain['name']: - raise exception.DomainNotFound(domain_id=domain_name) - return default_domain - def list_role_assignments(self): role_assignments = [] for a in self.role.list_role_assignments(self.project.tree_dn): @@ -415,20 +289,9 @@ class Assignment(assignment.Driver): # TODO(termie): turn this into a data object and move logic to driver -class ProjectApi(common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap): - DEFAULT_OU = 'ou=Groups' - DEFAULT_STRUCTURAL_CLASSES = [] - DEFAULT_OBJECTCLASS = 'groupOfNames' - DEFAULT_ID_ATTR = 'cn' - DEFAULT_MEMBER_ATTRIBUTE = 'member' - NotFound = exception.ProjectNotFound - notfound_arg = 'project_id' # NOTE(yorik-sar): while options_name = tenant - options_name = 'project' - attribute_options_names = {'name': 'name', - 'description': 'desc', - 'enabled': 'enabled', - 'domain_id': 'domain_id'} - immutable_attrs = ['name'] +class ProjectApi(common_ldap.ProjectLdapStructureMixin, + common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap): + model = models.Project def __init__(self, conf): @@ -436,12 +299,6 @@ class ProjectApi(common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap): self.member_attribute = (getattr(conf.ldap, 'project_member_attribute') or self.DEFAULT_MEMBER_ATTRIBUTE) - def create(self, values): - data = values.copy() - if data.get('id') is None: - data['id'] = uuid.uuid4().hex - return super(ProjectApi, self).create(data) - def get_user_projects(self, user_dn, associations): """Returns list of tenants a user has access to """ @@ -472,10 +329,6 @@ class ProjectApi(common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap): res.add(rolegrant.user_dn) return list(res) - def update(self, project_id, values): - old_obj = self.get(project_id) - return super(ProjectApi, self).update(project_id, values, old_obj) - class UserRoleAssociation(object): """Role Grant model.""" diff --git a/keystone/assignment/backends/sql.py b/keystone/assignment/backends/sql.py index 9b83333bfe..881300626d 100644 --- a/keystone/assignment/backends/sql.py +++ b/keystone/assignment/backends/sql.py @@ -17,11 +17,10 @@ import sqlalchemy from sqlalchemy.sql.expression import false from keystone import assignment as keystone_assignment -from keystone import clean from keystone.common import sql from keystone import config from keystone import exception -from keystone.i18n import _, _LE +from keystone.i18n import _ from keystone.openstack.common import log @@ -56,26 +55,8 @@ class Assignment(keystone_assignment.Driver): def default_role_driver(self): return "keystone.assignment.role_backends.sql.Role" - 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.transaction() as session: - return self._get_project(session, tenant_id).to_dict() - - def get_project_by_name(self, tenant_name, domain_id): - with sql.transaction() 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() + def default_resource_driver(self): + return 'keystone.resource.backends.sql.Resource' def list_user_ids_for_project(self, tenant_id): with sql.transaction() as session: @@ -190,39 +171,6 @@ class Assignment(keystone_assignment.Driver): if not q.delete(False): raise exception.RoleNotFound(role_id=role_id) - @sql.truncated - def list_projects(self, hints): - with sql.transaction() 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.transaction() 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.transaction() 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.transaction() 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 _list_project_ids_for_actor(self, actors, hints, inherited, group_only=False): # TODO(henry-nash): Now that we have a single assignment table, we @@ -278,59 +226,6 @@ class Assignment(keystone_assignment.Driver): return [assignment.target_id for assignment in query.all()] - 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.transaction() as session: - project = self._get_project(session, project_id).to_dict() - 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.union(children_ids) - subtree += children - children = self._get_children(session, children_ids) - return subtree - - def list_project_parents(self, project_id): - with sql.transaction() 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.transaction() as session: - project_refs = self._get_children(session, [project_id]) - return not project_refs - def list_role_ids_for_groups_on_domain(self, group_ids, domain_id): if not group_ids: # If there's no groups then there will be no domain roles. @@ -462,102 +357,6 @@ class Assignment(keystone_assignment.Driver): refs = session.query(RoleAssignment).all() return [denormalize_role(ref) for ref in 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() - - @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] - 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.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_domain(self, domain_id): - with sql.transaction() as session: - ref = self._get_domain(session, domain_id) - session.delete(ref) - def delete_project_assignments(self, project_id): with sql.transaction() as session: q = session.query(RoleAssignment) @@ -583,33 +382,6 @@ class Assignment(keystone_assignment.Driver): q.delete(False) -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'] - 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')) - # Unique constraint across two columns to create the separation - # rather than just only 'name' being unique - __table_args__ = (sql.UniqueConstraint('domain_id', 'name'), {}) - - class RoleAssignment(sql.ModelBase, sql.DictBase): __tablename__ = 'assignment' attributes = ['type', 'actor_id', 'target_id', 'role_id', 'inherited'] diff --git a/keystone/assignment/core.py b/keystone/assignment/core.py index 244a7d6693..c6acaf1c5d 100644 --- a/keystone/assignment/core.py +++ b/keystone/assignment/core.py @@ -18,7 +18,6 @@ import abc import six -from keystone import clean from keystone.common import cache from keystone.common import dependency from keystone.common import driver_hints @@ -26,7 +25,7 @@ from keystone.common import manager from keystone import config from keystone import exception from keystone.i18n import _ -from keystone.i18n import _LE, _LI +from keystone.i18n import _LI from keystone import notifications from keystone.openstack.common import log from keystone.openstack.common import versionutils @@ -34,21 +33,10 @@ from keystone.openstack.common import versionutils CONF = config.CONF LOG = log.getLogger(__name__) -ASSIGNMENT_SHOULD_CACHE = cache.should_cache_fn('assignment') -ROLE_SHOULD_CACHE = cache.should_cache_fn('role') +SHOULD_CACHE = cache.should_cache_fn('role') # NOTE(blk-u): The config option is not available at import time. -ASSIGNMENT_EXPIRATION_TIME = lambda: CONF.assignment.cache_time -ROLE_EXPIRATION_TIME = lambda: CONF.role.cache_time - - -def calc_default_domain(): - return {'description': - (u'Owns users and tenants (i.e. projects)' - ' available on Identity API v2.'), - 'enabled': True, - 'id': CONF.identity.default_domain_id, - 'name': u'Default'} +EXPIRATION_TIME = lambda: CONF.role.cache_time def deprecated_to_role_api(f): @@ -68,9 +56,27 @@ def deprecated_to_role_api(f): return wrapper() +def deprecated_to_resource_api(f): + """Specialized deprecation wrapper for assignment to resource api. + + This wraps the standard deprecation wrapper and fills in the method + names automatically. + + """ + @six.wraps(f) + def wrapper(*args, **kwargs): + x = versionutils.deprecated( + what='assignment.' + f.__name__ + '()', + as_of=versionutils.deprecated.KILO, + in_favor_of='resource.' + f.__name__ + '()') + return x(f) + return wrapper() + + @dependency.provider('assignment_api') @dependency.optional('revoke_api') -@dependency.requires('credential_api', 'identity_api', 'role_api') +@dependency.requires('credential_api', 'identity_api', 'resource_api', + 'role_api') class Manager(manager.Manager): """Default pivot point for the Assignment backend. @@ -87,8 +93,8 @@ class Manager(manager.Manager): # If there is no explicit assignment driver specified, we let the # identity driver tell us what to use. This is for backward - # compatibility reasons from the time when identity and assignment - # were all part of identity. + # compatibility reasons from the time when identity, resource and + # assignment were all part of identity. if assignment_driver is None: identity_driver = dependency.REGISTRY['identity_api'].driver assignment_driver = identity_driver.default_assignment_driver() @@ -102,159 +108,13 @@ class Manager(manager.Manager): x in self.identity_api.list_groups_for_user(user_id)] def list_user_ids_for_project(self, tenant_id): - self.driver.get_project(tenant_id) + self.resource_api.get_project(tenant_id) return self.driver.list_user_ids_for_project(tenant_id) - def _get_hierarchy_depth(self, parents_list): - return len(parents_list) + 1 - - def _assert_max_hierarchy_depth(self, project_id, parents_list=None): - if parents_list is None: - parents_list = self.list_project_parents(project_id) - max_depth = CONF.max_project_tree_depth - if self._get_hierarchy_depth(parents_list) > max_depth: - raise exception.ForbiddenAction( - action=_('max hierarchy depth reached for ' - '%s branch.') % project_id) - - @notifications.created(_PROJECT) - def create_project(self, tenant_id, tenant): - tenant = tenant.copy() - tenant.setdefault('enabled', True) - tenant['enabled'] = clean.project_enabled(tenant['enabled']) - tenant.setdefault('description', '') - tenant.setdefault('parent_id', None) - - if tenant.get('parent_id') is not None: - parent_ref = self.get_project(tenant.get('parent_id')) - parents_list = self.list_project_parents(parent_ref['id']) - parents_list.append(parent_ref) - for ref in parents_list: - if ref.get('domain_id') != tenant.get('domain_id'): - raise exception.ForbiddenAction( - action=_('cannot create a project within a different ' - 'domain than its parents.')) - if not ref.get('enabled', True): - raise exception.ForbiddenAction( - action=_('cannot create a project in a ' - 'branch containing a disabled ' - 'project: %s') % ref['id']) - self._assert_max_hierarchy_depth(tenant.get('parent_id'), - parents_list) - - ret = self.driver.create_project(tenant_id, tenant) - if ASSIGNMENT_SHOULD_CACHE(ret): - self.get_project.set(ret, self, tenant_id) - self.get_project_by_name.set(ret, self, ret['name'], - ret['domain_id']) - return ret - - def assert_domain_enabled(self, domain_id, domain=None): - """Assert the Domain is enabled. - - :raise AssertionError if domain is disabled. - """ - if domain is None: - domain = self.get_domain(domain_id) - if not domain.get('enabled', True): - raise AssertionError(_('Domain is disabled: %s') % domain_id) - - def assert_project_enabled(self, project_id, project=None): - """Assert the project is enabled and its associated domain is enabled. - - :raise AssertionError if the project or domain is disabled. - """ - if project is None: - project = self.get_project(project_id) - self.assert_domain_enabled(domain_id=project['domain_id']) - if not project.get('enabled', True): - raise AssertionError(_('Project is disabled: %s') % project_id) - - @notifications.disabled(_PROJECT, public=False) - def _disable_project(self, project_id): - """Emit a notification to the callback system project is been disabled. - - This method, and associated callback listeners, removes the need for - making direct calls to other managers to take action (e.g. revoking - project scoped tokens) when a project is disabled. - - :param project_id: project identifier - :type project_id: string - """ - pass - - def _assert_all_parents_are_enabled(self, project_id): - parents_list = self.list_project_parents(project_id) - for project in parents_list: - if not project.get('enabled', True): - raise exception.ForbiddenAction( - action=_('cannot enable project %s since it has ' - 'disabled parents') % project_id) - - def _assert_whole_subtree_is_disabled(self, project_id): - subtree_list = self.driver.list_projects_in_subtree(project_id) - for ref in subtree_list: - if ref.get('enabled', True): - raise exception.ForbiddenAction( - action=_('cannot disable project %s since ' - 'its subtree contains enabled ' - 'projects') % project_id) - - @notifications.updated(_PROJECT) - def update_project(self, tenant_id, tenant): - original_tenant = self.driver.get_project(tenant_id) - tenant = tenant.copy() - - parent_id = original_tenant.get('parent_id') - if 'parent_id' in tenant and tenant.get('parent_id') != parent_id: - raise exception.ForbiddenAction( - action=_('Update of `parent_id` is not allowed.')) - - if 'enabled' in tenant: - tenant['enabled'] = clean.project_enabled(tenant['enabled']) - - # NOTE(rodrigods): for the current implementation we only allow to - # disable a project if all projects below it in the hierarchy are - # already disabled. This also means that we can not enable a - # project that has disabled parents. - original_tenant_enabled = original_tenant.get('enabled', True) - tenant_enabled = tenant.get('enabled', True) - if not original_tenant_enabled and tenant_enabled: - self._assert_all_parents_are_enabled(tenant_id) - if original_tenant_enabled and not tenant_enabled: - self._assert_whole_subtree_is_disabled(tenant_id) - self._disable_project(tenant_id) - - ret = self.driver.update_project(tenant_id, tenant) - self.get_project.invalidate(self, tenant_id) - self.get_project_by_name.invalidate(self, original_tenant['name'], - original_tenant['domain_id']) - return ret - - @notifications.deleted(_PROJECT) - def delete_project(self, tenant_id): - if not self.driver.is_leaf_project(tenant_id): - raise exception.ForbiddenAction( - action=_('cannot delete the project %s since it is not ' - 'a leaf in the hierarchy.') % tenant_id) - - project = self.driver.get_project(tenant_id) - project_user_ids = self.list_user_ids_for_project(tenant_id) - for user_id in project_user_ids: - payload = {'user_id': user_id, 'project_id': tenant_id} - self._emit_invalidate_user_project_tokens_notification(payload) - self.driver.delete_project_assignments(tenant_id) - ret = self.driver.delete_project(tenant_id) - self.get_project.invalidate(self, tenant_id) - self.get_project_by_name.invalidate(self, project['name'], - project['domain_id']) - self.credential_api.delete_credentials_for_project(tenant_id) - return ret - def _list_parent_ids_of_project(self, project_id): if CONF.os_inherit.enabled: return [x['id'] for x in ( - self.driver.list_project_parents(project_id))] + self.resource_api.list_project_parents(project_id))] else: return [] @@ -307,7 +167,7 @@ class Manager(manager.Manager): return role_list - project_ref = self.get_project(tenant_id) + project_ref = self.resource_api.get_project(tenant_id) user_role_list = _get_user_project_roles(user_id, project_ref) group_role_list = _get_group_project_roles(user_id, project_ref) # Use set() to process the list to remove any duplicates @@ -361,7 +221,7 @@ class Manager(manager.Manager): """Get a list of roles for this group on domain and/or project.""" if project_id is not None: - project = self.driver.get_project(project_id) + project = self.resource_api.get_project(project_id) role_ids = self.driver.list_role_ids_for_groups_on_project( group_ids, project_id, project['domain_id'], self._list_parent_ids_of_project(project_id)) @@ -380,7 +240,7 @@ class Manager(manager.Manager): keystone.exception.UserNotFound """ - self.driver.get_project(tenant_id) + self.resource_api.get_project(tenant_id) try: self.role_api.get_role(config.CONF.member_role_id) self.driver.add_role_to_user_and_project( @@ -401,7 +261,7 @@ class Manager(manager.Manager): config.CONF.member_role_id) def add_role_to_user_and_project(self, user_id, tenant_id, role_id): - self.driver.get_project(tenant_id) + self.resource_api.get_project(tenant_id) self.role_api.get_role(role_id) self.driver.add_role_to_user_and_project(user_id, tenant_id, role_id) @@ -443,7 +303,7 @@ class Manager(manager.Manager): user_id, group_ids, hints or driver_hints.Hints()) if not CONF.os_inherit.enabled: - return self.driver.list_projects_from_ids(project_ids) + return self.resource_api.list_projects_from_ids(project_ids) # Inherited roles are enabled, so check to see if this user has any # inherited role (direct or group) on any parent project, in which @@ -454,66 +314,16 @@ class Manager(manager.Manager): for proj_id in project_ids_inherited: project_ids.update( (x['id'] for x in - self.driver.list_projects_in_subtree(proj_id))) + self.resource_api.list_projects_in_subtree(proj_id))) # Now do the same for any domain inherited roles domain_ids = self.driver.list_domain_ids_for_user( user_id, group_ids, hints or driver_hints.Hints(), inherited=True) project_ids.update( - self.driver.list_project_ids_from_domain_ids(domain_ids)) + self.resource_api.list_project_ids_from_domain_ids(domain_ids)) - return self.driver.list_projects_from_ids(list(project_ids)) - - def _filter_projects_list(self, projects_list, user_id): - user_projects = self.list_projects_for_user(user_id) - user_projects_ids = set([proj['id'] for proj in user_projects]) - # Keep only the projects present in user_projects - projects_list = [proj for proj in projects_list - if proj['id'] in user_projects_ids] - - def list_project_parents(self, project_id, user_id=None): - parents = self.driver.list_project_parents(project_id) - # If a user_id was provided, the returned list should be filtered - # against the projects this user has access to. - if user_id: - self._filter_projects_list(parents, user_id) - return parents - - def list_projects_in_subtree(self, project_id, user_id=None): - subtree = self.driver.list_projects_in_subtree(project_id) - # If a user_id was provided, the returned list should be filtered - # against the projects this user has access to. - if user_id: - self._filter_projects_list(subtree, user_id) - return subtree - - @cache.on_arguments(should_cache_fn=ASSIGNMENT_SHOULD_CACHE, - expiration_time=ASSIGNMENT_EXPIRATION_TIME) - def get_domain(self, domain_id): - return self.driver.get_domain(domain_id) - - @cache.on_arguments(should_cache_fn=ASSIGNMENT_SHOULD_CACHE, - expiration_time=ASSIGNMENT_EXPIRATION_TIME) - def get_domain_by_name(self, domain_name): - return self.driver.get_domain_by_name(domain_name) - - @notifications.created('domain') - def create_domain(self, domain_id, domain): - if (not self.identity_api.multiple_domains_supported and - domain_id != CONF.identity.default_domain_id): - raise exception.Forbidden(_('Multiple domains are not supported')) - domain.setdefault('enabled', True) - domain['enabled'] = clean.domain_enabled(domain['enabled']) - ret = self.driver.create_domain(domain_id, domain) - if ASSIGNMENT_SHOULD_CACHE(ret): - self.get_domain.set(ret, self, domain_id) - self.get_domain_by_name.set(ret, self, ret['name']) - return ret - - @manager.response_truncated - def list_domains(self, hints=None): - return self.driver.list_domains(hints or driver_hints.Hints()) + return self.resource_api.list_projects_from_ids(list(project_ids)) # TODO(henry-nash): We might want to consider list limiting this at some # point in the future. @@ -527,163 +337,18 @@ class Manager(manager.Manager): group_ids = self._get_group_ids_for_user_id(user_id) domain_ids = self.driver.list_domain_ids_for_user( user_id, group_ids, hints or driver_hints.Hints()) - return self.driver.list_domains_from_ids(domain_ids) + return self.resource_api.list_domains_from_ids(domain_ids) def list_domains_for_groups(self, group_ids): domain_ids = self.driver.list_domain_ids_for_groups(group_ids) - return self.driver.list_domains_from_ids(domain_ids) - - @notifications.disabled('domain', public=False) - def _disable_domain(self, domain_id): - """Emit a notification to the callback system domain is been disabled. - - This method, and associated callback listeners, removes the need for - making direct calls to other managers to take action (e.g. revoking - domain scoped tokens) when a domain is disabled. - - :param domain_id: domain identifier - :type domain_id: string - """ - pass - - @notifications.updated('domain') - def update_domain(self, 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) - # disable owned users & projects when the API user specifically set - # enabled=False - if (original_domain.get('enabled', True) and - not domain.get('enabled', True)): - self._disable_domain(domain_id) - self.get_domain.invalidate(self, domain_id) - self.get_domain_by_name.invalidate(self, original_domain['name']) - return ret - - @notifications.deleted('domain') - def delete_domain(self, domain_id): - # explicitly forbid deleting the default domain (this should be a - # carefully orchestrated manual process involving configuration - # changes, etc) - if domain_id == CONF.identity.default_domain_id: - raise exception.ForbiddenAction(action=_('delete the default ' - 'domain')) - - domain = self.driver.get_domain(domain_id) - - # To help avoid inadvertent deletes, we insist that the domain - # has been previously disabled. This also prevents a user deleting - # their own domain since, once it is disabled, they won't be able - # to get a valid token to issue this delete. - if domain['enabled']: - raise exception.ForbiddenAction( - action=_('cannot delete a domain that is enabled, ' - 'please disable it first.')) - - self._delete_domain_contents(domain_id) - # TODO(henry-nash): Although the controller will ensure deletion of - # all users & groups within the domain (which will cause all - # assignments for those users/groups to also be deleted), there - # could still be assignments on this domain for users/groups in - # other domains - so we should delete these here by making a call - # to the backend to delete all assignments for this domain. - # (see Bug #1277847) - self.driver.delete_domain(domain_id) - self.get_domain.invalidate(self, domain_id) - self.get_domain_by_name.invalidate(self, domain['name']) - - def _delete_domain_contents(self, domain_id): - """Delete the contents of a domain. - - Before we delete a domain, we need to remove all the entities - that are owned by it, i.e. Users, Groups & Projects. To do this we - call the respective delete functions for these entities, which are - themselves responsible for deleting any credentials and role grants - associated with them as well as revoking any relevant tokens. - - The order we delete entities is also important since some types - of backend may need to maintain referential integrity - throughout, and many of the entities have relationship with each - other. The following deletion order is therefore used: - - Projects: Reference user and groups for grants - Groups: Reference users for membership and domains for grants - Users: Reference domains for grants - - """ - - def _delete_projects(project, projects, examined): - if project['id'] in examined: - msg = _LE('Circular reference or a repeated entry found ' - 'projects hierarchy - %(project_id)s.') - LOG.error(msg, {'project_id': project['id']}) - return - - examined.add(project['id']) - children = [proj for proj in projects - if proj.get('parent_id') == project['id']] - for proj in children: - _delete_projects(proj, projects, examined) - - try: - self.delete_project(project['id']) - except exception.ProjectNotFound: - LOG.debug(('Project %(projectid)s not found when ' - 'deleting domain contents for %(domainid)s, ' - 'continuing with cleanup.'), - {'projectid': project['id'], - 'domainid': domain_id}) - - user_refs = self.identity_api.list_users(domain_scope=domain_id) - proj_refs = self.list_projects_in_domain(domain_id) - group_refs = self.identity_api.list_groups(domain_scope=domain_id) - - # Deleting projects recursively - roots = [x for x in proj_refs if x.get('parent_id') is None] - examined = set() - for project in roots: - _delete_projects(project, proj_refs, examined) - - for group in group_refs: - # Cleanup any existing groups. - if group['domain_id'] == domain_id: - try: - self.identity_api.delete_group(group['id']) - except exception.GroupNotFound: - LOG.debug(('Group %(groupid)s not found when deleting ' - 'domain contents for %(domainid)s, continuing ' - 'with cleanup.'), - {'groupid': group['id'], 'domainid': domain_id}) - - # And finally, delete the users themselves - for user in user_refs: - if user['domain_id'] == domain_id: - try: - self.identity_api.delete_user(user['id']) - except exception.UserNotFound: - LOG.debug(('User %(userid)s not found when ' - 'deleting domain contents for %(domainid)s, ' - 'continuing with cleanup.'), - {'userid': user['id'], - 'domainid': domain_id}) - - @manager.response_truncated - def list_projects(self, hints=None): - return self.driver.list_projects(hints or driver_hints.Hints()) - - # NOTE(henry-nash): list_projects_in_domain is actually an internal method - # and not exposed via the API. Therefore there is no need to support - # driver hints for it. - def list_projects_in_domain(self, domain_id): - return self.driver.list_projects_in_domain(domain_id) + return self.resource_api.list_domains_from_ids(domain_ids) def list_projects_for_groups(self, group_ids): project_ids = ( self.driver.list_project_ids_for_groups(group_ids, driver_hints.Hints())) if not CONF.os_inherit.enabled: - return self.driver.list_projects_from_ids(project_ids) + return self.resource_api.list_projects_from_ids(project_ids) # Inherited roles are enabled, so check to see if these groups have any # roles on any domain, in which case we must add in all the projects @@ -693,21 +358,11 @@ class Manager(manager.Manager): group_ids, inherited=True) project_ids_from_domains = ( - self.driver.list_project_ids_from_domain_ids(domain_ids)) + self.resource_api.list_project_ids_from_domain_ids(domain_ids)) - return self.driver.list_projects_from_ids( + return self.resource_api.list_projects_from_ids( list(set(project_ids + project_ids_from_domains))) - @cache.on_arguments(should_cache_fn=ASSIGNMENT_SHOULD_CACHE, - expiration_time=ASSIGNMENT_EXPIRATION_TIME) - def get_project(self, project_id): - return self.driver.get_project(project_id) - - @cache.on_arguments(should_cache_fn=ASSIGNMENT_SHOULD_CACHE, - expiration_time=ASSIGNMENT_EXPIRATION_TIME) - def get_project_by_name(self, tenant_name, domain_id): - return self.driver.get_project_by_name(tenant_name, domain_id) - def list_role_assignments_for_role(self, role_id=None): # NOTE(henry-nash): Currently the efficiency of the key driver # implementation (SQL) of list_role_assignments is severely hampered by @@ -737,9 +392,9 @@ class Manager(manager.Manager): inherited_to_projects=False, context=None): self.role_api.get_role(role_id) if domain_id: - self.driver.get_domain(domain_id) + self.resource_api.get_domain(domain_id) if project_id: - self.driver.get_project(project_id) + self.resource_api.get_project(project_id) self.driver.create_grant(role_id, user_id, group_id, domain_id, project_id, inherited_to_projects) @@ -748,9 +403,9 @@ class Manager(manager.Manager): inherited_to_projects=False): role_ref = self.role_api.get_role(role_id) if domain_id: - self.driver.get_domain(domain_id) + self.resource_api.get_domain(domain_id) if project_id: - self.driver.get_project(project_id) + self.resource_api.get_project(project_id) self.driver.check_grant_role_id( role_id, user_id, group_id, domain_id, project_id, inherited_to_projects) @@ -760,9 +415,9 @@ class Manager(manager.Manager): domain_id=None, project_id=None, inherited_to_projects=False): if domain_id: - self.driver.get_domain(domain_id) + self.resource_api.get_domain(domain_id) if project_id: - self.driver.get_project(project_id) + self.resource_api.get_project(project_id) grant_ids = self.driver.list_grant_role_ids( user_id, group_id, domain_id, project_id, inherited_to_projects) return self.role_api.list_roles_from_ids(grant_ids) @@ -800,9 +455,9 @@ class Manager(manager.Manager): self.role_api.get_role(role_id) if domain_id: - self.driver.get_domain(domain_id) + self.resource_api.get_domain(domain_id) if project_id: - self.driver.get_project(project_id) + self.resource_api.get_project(project_id) self.driver.delete_grant(role_id, user_id, group_id, domain_id, project_id, inherited_to_projects) if user_id is not None: @@ -900,6 +555,78 @@ class Manager(manager.Manager): def list_roles(self, hints=None): return self.role_api.list_roles(hints=hints) + @deprecated_to_resource_api + def create_project(self, project_id, project): + return self.resource_api.create_project(project_id, project) + + @deprecated_to_resource_api + def get_project_by_name(self, tenant_name, domain_id): + return self.resource_api.get_project_by_name(tenant_name, domain_id) + + @deprecated_to_resource_api + def get_project(self, project_id): + return self.resource_api.get_project(project_id) + + @deprecated_to_resource_api + def update_project(self, project_id, project): + return self.resource_api.update_project(project_id, project) + + @deprecated_to_resource_api + def delete_project(self, project_id): + return self.resource_api.delete_project(project_id) + + @deprecated_to_resource_api + def list_projects(self, hints=None): + return self.resource_api.list_projects(hints=hints) + + @deprecated_to_resource_api + def list_projects_in_domain(self, domain_id): + return self.resource_api.list_projects_in_domain(domain_id) + + @deprecated_to_resource_api + def create_domain(self, domain_id, domain): + return self.resource_api.create_domain(domain_id, domain) + + @deprecated_to_resource_api + def get_domain_by_name(self, domain_name): + return self.resource_api.get_domain_by_name(domain_name) + + @deprecated_to_resource_api + def get_domain(self, domain_id): + return self.resource_api.get_domain(domain_id) + + @deprecated_to_resource_api + def update_domain(self, domain_id, domain): + return self.resource_api.update_domain(domain_id, domain) + + @deprecated_to_resource_api + def delete_domain(self, domain_id): + return self.resource_api.delete_domain(domain_id) + + @deprecated_to_resource_api + def list_domains(self, hints=None): + return self.resource_api.list_domains(hints=hints) + + @deprecated_to_resource_api + def assert_domain_enabled(self, domain_id, domain=None): + return self.resource_api.assert_domain_enabled(domain_id, domain) + + @deprecated_to_resource_api + def assert_project_enabled(self, project_id, project=None): + return self.resource_api.assert_project_enabled(project_id, project) + + @deprecated_to_resource_api + def is_leaf_project(self, project_id): + return self.resource_api.is_leaf_project(project_id) + + @deprecated_to_resource_api + def list_project_parents(self, project_id, user_id=None): + return self.resource_api.list_project_parents(project_id, user_id) + + @deprecated_to_resource_api + def list_projects_in_subtree(self, project_id, user_id=None): + return self.resource_api.list_projects_in_subtree(project_id, user_id) + @six.add_metaclass(abc.ABCMeta) class Driver(object): @@ -941,16 +668,6 @@ class Driver(object): def _get_list_limit(self): return CONF.assignment.list_limit or CONF.list_limit - @abc.abstractmethod - def get_project_by_name(self, tenant_name, domain_id): - """Get a tenant by name. - - :returns: tenant_ref - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - @abc.abstractmethod def list_user_ids_for_project(self, tenant_id): """Lists all user IDs with a role assignment in the specified project. @@ -1030,143 +747,6 @@ class Driver(object): raise exception.NotImplemented() # pragma: no cover - # domain crud - @abc.abstractmethod - def create_domain(self, domain_id, domain): - """Creates a new domain. - - :raises: keystone.exception.Conflict - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_domains(self, hints): - """List domains in the system. - - :param hints: filter hints which the driver should - implement if at all possible. - - :returns: a list of domain_refs or an empty list. - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_domains_from_ids(self, domain_ids): - """List domains for the provided list of ids. - - :param domain_ids: list of ids - - :returns: a list of domain_refs. - - This method is used internally by the assignment manager to bulk read - a set of domains given their ids. - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def get_domain(self, domain_id): - """Get a domain by ID. - - :returns: domain_ref - :raises: keystone.exception.DomainNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def get_domain_by_name(self, domain_name): - """Get a domain by name. - - :returns: domain_ref - :raises: keystone.exception.DomainNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def update_domain(self, domain_id, domain): - """Updates an existing domain. - - :raises: keystone.exception.DomainNotFound, - keystone.exception.Conflict - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def delete_domain(self, domain_id): - """Deletes an existing domain. - - :raises: keystone.exception.DomainNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - - # project crud - @abc.abstractmethod - def create_project(self, project_id, project): - """Creates a new project. - - :raises: keystone.exception.Conflict - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_projects(self, hints): - """List projects in the system. - - :param hints: filter hints which the driver should - implement if at all possible. - - :returns: a list of project_refs or an empty list. - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_projects_from_ids(self, project_ids): - """List projects for the provided list of ids. - - :param project_ids: list of ids - - :returns: a list of project_refs. - - This method is used internally by the assignment manager to bulk read - a set of projects given their ids. - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_project_ids_from_domain_ids(self, domain_ids): - """List project ids for the provided list of domain ids. - - :param domain_ids: list of domain ids - - :returns: a list of project ids owned by the specified domain ids. - - This method is used internally by the assignment manager to bulk read - a set of project ids given a list of domain ids. - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def list_projects_in_domain(self, domain_id): - """List projects in the domain. - - :param domain_id: the driver MUST only return projects - within this domain. - - :returns: a list of project_refs or an empty list. - - """ - raise exception.NotImplemented() # pragma: no cover - @abc.abstractmethod def list_project_ids_for_user(self, user_id, group_ids, hints, inherited=False): @@ -1190,45 +770,6 @@ class Driver(object): """ raise exception.NotImplemented() # pragma: no cover - @abc.abstractmethod - def list_project_parents(self, project_id): - """List all parents from a project by its ID. - - :param project_id: the driver will list the parents of this - project. - - :returns: a list of project_refs or an empty list. - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() - - @abc.abstractmethod - def list_projects_in_subtree(self, project_id): - """List all projects in the subtree below the hierarchy of the - given project. - - :param project_id: the driver will get the subtree under - this project. - - :returns: a list of project_refs or an empty list - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() - - @abc.abstractmethod - def is_leaf_project(self, project_id): - """Checks if a project is a leaf in the hierarchy. - - :param project_id: the driver will check if this project - is a leaf in the hierarchy. - - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() - @abc.abstractmethod def list_project_ids_for_groups(self, group_ids, hints, inherited=False): @@ -1279,35 +820,6 @@ class Driver(object): """ raise exception.NotImplemented() # pragma: no cover - @abc.abstractmethod - def get_project(self, project_id): - """Get a project by ID. - - :returns: project_ref - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def update_project(self, project_id, project): - """Updates an existing project. - - :raises: keystone.exception.ProjectNotFound, - keystone.exception.Conflict - - """ - raise exception.NotImplemented() # pragma: no cover - - @abc.abstractmethod - def delete_project(self, project_id): - """Deletes an existing project. - - :raises: keystone.exception.ProjectNotFound - - """ - raise exception.NotImplemented() # pragma: no cover - @abc.abstractmethod def list_role_ids_for_groups_on_project( self, group_ids, project_id, project_domain_id, project_parents): @@ -1379,40 +891,6 @@ class Driver(object): """ raise exception.NotImplemented() # pragma: no cover - # Domain management functions for backends that only allow a single - # domain. Currently, this is only LDAP, but might be used by other - # backends in the future. - def _set_default_domain(self, ref): - """If the domain ID has not been set, set it to the default.""" - if isinstance(ref, dict): - if 'domain_id' not in ref: - ref = ref.copy() - ref['domain_id'] = CONF.identity.default_domain_id - return ref - elif isinstance(ref, list): - return [self._set_default_domain(x) for x in ref] - else: - raise ValueError(_('Expected dict or list: %s') % type(ref)) - - def _validate_default_domain(self, ref): - """Validate that either the default domain or nothing is specified. - - Also removes the domain from the ref so that LDAP doesn't have to - persist the attribute. - - """ - ref = ref.copy() - domain_id = ref.pop('domain_id', CONF.identity.default_domain_id) - self._validate_default_domain_id(domain_id) - return ref - - def _validate_default_domain_id(self, domain_id): - """Validate that the domain ID specified belongs to the default domain. - - """ - if domain_id != CONF.identity.default_domain_id: - raise exception.DomainNotFound(domain_id=domain_id) - @dependency.provider('role_api') @dependency.requires('assignment_api') @@ -1430,15 +908,15 @@ class RoleManager(manager.Manager): super(RoleManager, self).__init__(role_driver) - @cache.on_arguments(should_cache_fn=ROLE_SHOULD_CACHE, - expiration_time=ROLE_EXPIRATION_TIME) + @cache.on_arguments(should_cache_fn=SHOULD_CACHE, + expiration_time=EXPIRATION_TIME) def get_role(self, role_id): return self.driver.get_role(role_id) @notifications.created('role') def create_role(self, role_id, role): ret = self.driver.create_role(role_id, role) - if ROLE_SHOULD_CACHE(ret): + if SHOULD_CACHE(ret): self.get_role.set(ret, self, role_id) return ret diff --git a/keystone/backends.py b/keystone/backends.py index eca8756cf3..0eda6250e6 100644 --- a/keystone/backends.py +++ b/keystone/backends.py @@ -19,6 +19,7 @@ from keystone.contrib import endpoint_policy from keystone import credential from keystone import identity from keystone import policy +from keystone import resource from keystone import token from keystone import trust @@ -28,13 +29,15 @@ def load_backends(): # Configure and build the cache cache.configure_cache_region(cache.REGION) - # Ensure that the identity driver is created before the assignment manager. - # The default assignment driver is determined by the identity driver, so - # the identity driver must be available to the assignment manager. + # Ensure that the identity driver is created before the assignment manager + # and that the assignment driver is created before the resource manager. + # The default resource driver depends on assignment, which in turn + # depends on identity - hence we need to ensure the chain is available. _IDENTITY_API = identity.Manager() + _ASSIGNMENT_API = assignment.Manager() DRIVERS = dict( - assignment_api=assignment.Manager(), + assignment_api=_ASSIGNMENT_API, catalog_api=catalog.Manager(), credential_api=credential.Manager(), endpoint_filter_api=endpoint_filter.Manager(), @@ -43,6 +46,7 @@ def load_backends(): id_mapping_api=identity.MappingManager(), identity_api=_IDENTITY_API, policy_api=policy.Manager(), + resource_api=resource.Manager(), role_api=assignment.RoleManager(), token_api=token.persistence.Manager(), trust_api=trust.Manager(), diff --git a/keystone/common/config.py b/keystone/common/config.py index e182ea5c93..6eb6474e4f 100644 --- a/keystone/common/config.py +++ b/keystone/common/config.py @@ -439,15 +439,27 @@ FILE_OPTIONS = { # the backend cfg.StrOpt('driver', help='Assignment backend driver.'), + ], + 'resource': [ + cfg.StrOpt('driver', + help='Resource backend driver. If a resource driver is ' + 'not specified, the assignment driver will choose ' + 'the resource driver.'), cfg.BoolOpt('caching', default=True, - help='Toggle for assignment caching. This has no effect ' + deprecated_opts=[cfg.DeprecatedOpt('caching', + group='assignment')], + help='Toggle for resource caching. This has no effect ' 'unless global caching is enabled.'), cfg.IntOpt('cache_time', - help='TTL (in seconds) to cache assignment data. This has ' + deprecated_opts=[cfg.DeprecatedOpt('cache_time', + group='assignment')], + help='TTL (in seconds) to cache resource data. This has ' 'no effect unless global caching is enabled.'), cfg.IntOpt('list_limit', + deprecated_opts=[cfg.DeprecatedOpt('list_limit', + group='assignment')], help='Maximum number of entities that will be returned ' - 'in an assignment collection.'), + 'in a resource collection.'), ], 'role': [ # The role driver has no default for backward compatibility reasons. diff --git a/keystone/common/ldap/core.py b/keystone/common/ldap/core.py index 482ecab549..ce6a1720a5 100644 --- a/keystone/common/ldap/core.py +++ b/keystone/common/ldap/core.py @@ -1804,3 +1804,23 @@ class EnabledEmuMixIn(BaseLdap): if self.enabled_emulation: self._remove_enabled(object_id) super(EnabledEmuMixIn, self).delete(object_id) + + +class ProjectLdapStructureMixin(object): + """Project LDAP Structure shared between LDAP backends. + + This is shared between the resource and assignment LDAP backends. + + """ + DEFAULT_OU = 'ou=Groups' + DEFAULT_STRUCTURAL_CLASSES = [] + DEFAULT_OBJECTCLASS = 'groupOfNames' + DEFAULT_ID_ATTR = 'cn' + NotFound = exception.ProjectNotFound + notfound_arg = 'project_id' # NOTE(yorik-sar): while options_name = tenant + options_name = 'project' + attribute_options_names = {'name': 'name', + 'description': 'desc', + 'enabled': 'enabled', + 'domain_id': 'domain_id'} + immutable_attrs = ['name'] diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index a52c33aa23..0292635adf 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -19,9 +19,9 @@ from keystone import exception from keystone.i18n import _ from keystone import identity -# Import assignment sql to ensure that the models defined in there are +# Import resource sql to ensure that the models defined in there are # available for the reference from User and Group to Domain.id. -from keystone.assignment.backends import sql as assignment_sql # noqa +from keystone.resource.backends import sql as resource_sql # noqa CONF = config.CONF diff --git a/keystone/resource/__init__.py b/keystone/resource/__init__.py new file mode 100644 index 0000000000..c9ca13901d --- /dev/null +++ b/keystone/resource/__init__.py @@ -0,0 +1,13 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystone.resource.core import * # noqa diff --git a/keystone/resource/backends/__init__.py b/keystone/resource/backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/resource/backends/ldap.py b/keystone/resource/backends/ldap.py new file mode 100644 index 0000000000..ed2d32ebc5 --- /dev/null +++ b/keystone/resource/backends/ldap.py @@ -0,0 +1,191 @@ +# 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 __future__ import absolute_import + +import uuid + +from keystone import clean +from keystone.common import driver_hints +from keystone.common import ldap as common_ldap +from keystone.common import models +from keystone import config +from keystone import exception +from keystone.i18n import _ +from keystone.identity.backends import ldap as ldap_identity +from keystone.openstack.common import log +from keystone import resource + + +CONF = config.CONF +LOG = log.getLogger(__name__) + + +class Resource(resource.Driver): + def __init__(self): + super(Resource, self).__init__() + self.LDAP_URL = CONF.ldap.url + self.LDAP_USER = CONF.ldap.user + self.LDAP_PASSWORD = CONF.ldap.password + self.suffix = CONF.ldap.suffix + + # This is the only deep dependency from resource back + # to identity. The assumption is that if you are using + # LDAP for resource, you are using it for identity as well. + self.user = ldap_identity.UserApi(CONF) + + self.project = ProjectApi(CONF) + + def default_assignment_driver(self): + return 'keystone.assignment.backends.ldap.Assignment' + + def _set_default_parent_project(self, ref): + """If the parent project ID has not been set, set it to None.""" + if isinstance(ref, dict): + if 'parent_id' not in ref: + ref = dict(ref, parent_id=None) + return ref + elif isinstance(ref, list): + return [self._set_default_parent_project(x) for x in ref] + else: + raise ValueError(_('Expected dict or list: %s') % type(ref)) + + def _validate_parent_project_is_none(self, ref): + """If a parent_id different from None was given, + raises InvalidProjectException. + + """ + parent_id = ref.get('parent_id') + if parent_id is not None: + raise exception.InvalidParentProject(parent_id) + + def _set_default_attributes(self, project_ref): + project_ref = self._set_default_domain(project_ref) + return self._set_default_parent_project(project_ref) + + def get_project(self, tenant_id): + return self._set_default_attributes( + self.project.get(tenant_id)) + + def list_projects(self, hints): + return self._set_default_attributes( + self.project.get_all()) + + def list_projects_in_domain(self, domain_id): + # We don't support multiple domains within this driver, so ignore + # any domain specified + return self.list_projects(driver_hints.Hints()) + + def list_projects_in_subtree(self, project_id): + # We don't support projects hierarchy within this driver, so a + # project will never have children + return [] + + def list_project_parents(self, project_id): + # We don't support projects hierarchy within this driver, so a + # project will never have parents + return [] + + def is_leaf_project(self, project_id): + # We don't support projects hierarchy within this driver, so a + # project will always be a root and a leaf at the same time + return True + + def list_projects_from_ids(self, ids): + return [self.get_project(id) for id in ids] + + def list_project_ids_from_domain_ids(self, domain_ids): + # We don't support multiple domains within this driver, so ignore + # any domain specified + return [x.id for x in self.list_projects(driver_hints.Hints())] + + def get_project_by_name(self, tenant_name, domain_id): + self._validate_default_domain_id(domain_id) + return self._set_default_attributes( + self.project.get_by_name(tenant_name)) + + def create_project(self, tenant_id, tenant): + self.project.check_allow_create() + tenant = self._validate_default_domain(tenant) + self._validate_parent_project_is_none(tenant) + tenant['name'] = clean.project_name(tenant['name']) + data = tenant.copy() + if 'id' not in data or data['id'] is None: + data['id'] = str(uuid.uuid4().hex) + if 'description' in data and data['description'] in ['', None]: + data.pop('description') + return self._set_default_attributes( + self.project.create(data)) + + def update_project(self, tenant_id, tenant): + self.project.check_allow_update() + tenant = self._validate_default_domain(tenant) + if 'name' in tenant: + tenant['name'] = clean.project_name(tenant['name']) + return self._set_default_attributes( + self.project.update(tenant_id, tenant)) + + def delete_project(self, tenant_id): + self.project.check_allow_delete() + if self.project.subtree_delete_enabled: + self.project.deleteTree(tenant_id) + else: + # The manager layer will call assignments to delete the + # role assignments, so we just have to delete the project itself. + self.project.delete(tenant_id) + + def create_domain(self, domain_id, domain): + if domain_id == CONF.identity.default_domain_id: + msg = _('Duplicate ID, %s.') % domain_id + raise exception.Conflict(type='domain', details=msg) + raise exception.Forbidden(_('Domains are read-only against LDAP')) + + def get_domain(self, domain_id): + self._validate_default_domain_id(domain_id) + return resource.calc_default_domain() + + def update_domain(self, domain_id, domain): + self._validate_default_domain_id(domain_id) + raise exception.Forbidden(_('Domains are read-only against LDAP')) + + def delete_domain(self, domain_id): + self._validate_default_domain_id(domain_id) + raise exception.Forbidden(_('Domains are read-only against LDAP')) + + def list_domains(self, hints): + return [resource.calc_default_domain()] + + def list_domains_from_ids(self, ids): + return [resource.calc_default_domain()] + + def get_domain_by_name(self, domain_name): + default_domain = resource.calc_default_domain() + if domain_name != default_domain['name']: + raise exception.DomainNotFound(domain_id=domain_name) + return default_domain + + +# TODO(termie): turn this into a data object and move logic to driver +class ProjectApi(common_ldap.ProjectLdapStructureMixin, + common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap): + + model = models.Project + + def create(self, values): + data = values.copy() + if data.get('id') is None: + data['id'] = uuid.uuid4().hex + return super(ProjectApi, self).create(data) + + def update(self, project_id, values): + old_obj = self.get(project_id) + return super(ProjectApi, self).update(project_id, values, old_obj) diff --git a/keystone/resource/backends/sql.py b/keystone/resource/backends/sql.py new file mode 100644 index 0000000000..64e71c7b5a --- /dev/null +++ b/keystone/resource/backends/sql.py @@ -0,0 +1,259 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystone import clean +from keystone.common import sql +from keystone import config +from keystone import exception +from keystone.i18n import _LE +from keystone.openstack.common import log +from keystone import resource as keystone_resource + + +CONF = config.CONF +LOG = log.getLogger(__name__) + + +class Resource(keystone_resource.Driver): + + def default_assignment_driver(self): + return 'keystone.assignment.backends.sql.Assignment' + + 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.transaction() as session: + return self._get_project(session, tenant_id).to_dict() + + def get_project_by_name(self, tenant_name, domain_id): + with sql.transaction() 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() + + @sql.truncated + def list_projects(self, hints): + with sql.transaction() 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.transaction() 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.transaction() 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.transaction() 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.transaction() as session: + project = self._get_project(session, project_id).to_dict() + 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.union(children_ids) + subtree += children + children = self._get_children(session, children_ids) + return subtree + + def list_project_parents(self, project_id): + with sql.transaction() 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.transaction() 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() + + @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] + 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.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_domain(self, domain_id): + with sql.transaction() 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'] + 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')) + # 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/resource/core.py b/keystone/resource/core.py new file mode 100644 index 0000000000..c860245e0a --- /dev/null +++ b/keystone/resource/core.py @@ -0,0 +1,685 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Main entry point into the resource service.""" + +import abc + +import six + +from keystone import clean +from keystone.common import cache +from keystone.common import dependency +from keystone.common import driver_hints +from keystone.common import manager +from keystone import config +from keystone import exception +from keystone.i18n import _, _LE +from keystone import notifications +from keystone.openstack.common import log + + +CONF = config.CONF +LOG = log.getLogger(__name__) +SHOULD_CACHE = cache.should_cache_fn('resource') + +# NOTE(blk-u): The config options are not available at import time. +EXPIRATION_TIME = lambda: CONF.resource.cache_time + + +def calc_default_domain(): + return {'description': + (u'Owns users and tenants (i.e. projects)' + ' available on Identity API v2.'), + 'enabled': True, + 'id': CONF.identity.default_domain_id, + 'name': u'Default'} + + +@dependency.provider('resource_api') +@dependency.optional('revoke_api') +@dependency.requires('assignment_api', 'credential_api', 'identity_api') +class Manager(manager.Manager): + """Default pivot point for the resource backend. + + See :mod:`keystone.common.manager.Manager` for more details on how this + dynamically calls the backend. + + """ + _PROJECT = 'project' + + def __init__(self): + # If there is a specific driver specified for resource, then use it. + # Otherwise retrieve the driver type from the assignment driver. + resource_driver = CONF.resource.driver + + if resource_driver is None: + assignment_driver = dependency.REGISTRY['assignment_api'].driver + resource_driver = assignment_driver.default_resource_driver() + + super(Manager, self).__init__(resource_driver) + + def _get_hierarchy_depth(self, parents_list): + return len(parents_list) + 1 + + def _assert_max_hierarchy_depth(self, project_id, parents_list=None): + if parents_list is None: + parents_list = self.list_project_parents(project_id) + max_depth = CONF.max_project_tree_depth + if self._get_hierarchy_depth(parents_list) > max_depth: + raise exception.ForbiddenAction( + action=_('max hierarchy depth reached for ' + '%s branch.') % project_id) + + @notifications.created(_PROJECT) + def create_project(self, tenant_id, tenant): + tenant = tenant.copy() + tenant.setdefault('enabled', True) + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + tenant.setdefault('description', '') + tenant.setdefault('parent_id', None) + + if tenant.get('parent_id') is not None: + parent_ref = self.get_project(tenant.get('parent_id')) + parents_list = self.list_project_parents(parent_ref['id']) + parents_list.append(parent_ref) + for ref in parents_list: + if ref.get('domain_id') != tenant.get('domain_id'): + raise exception.ForbiddenAction( + action=_('cannot create a project within a different ' + 'domain than its parents.')) + if not ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot create a project in a ' + 'branch containing a disabled ' + 'project: %s') % ref['id']) + self._assert_max_hierarchy_depth(tenant.get('parent_id'), + parents_list) + + ret = self.driver.create_project(tenant_id, tenant) + if SHOULD_CACHE(ret): + self.get_project.set(ret, self, tenant_id) + self.get_project_by_name.set(ret, self, ret['name'], + ret['domain_id']) + return ret + + def assert_domain_enabled(self, domain_id, domain=None): + """Assert the Domain is enabled. + + :raise AssertionError if domain is disabled. + """ + if domain is None: + domain = self.get_domain(domain_id) + if not domain.get('enabled', True): + raise AssertionError(_('Domain is disabled: %s') % domain_id) + + def assert_project_enabled(self, project_id, project=None): + """Assert the project is enabled and its associated domain is enabled. + + :raise AssertionError if the project or domain is disabled. + """ + if project is None: + project = self.get_project(project_id) + self.assert_domain_enabled(domain_id=project['domain_id']) + if not project.get('enabled', True): + raise AssertionError(_('Project is disabled: %s') % project_id) + + @notifications.disabled(_PROJECT, public=False) + def _disable_project(self, project_id): + """Emit a notification to the callback system project is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + project scoped tokens) when a project is disabled. + + :param project_id: project identifier + :type project_id: string + """ + pass + + def _assert_all_parents_are_enabled(self, project_id): + parents_list = self.list_project_parents(project_id) + for project in parents_list: + if not project.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot enable project %s since it has ' + 'disabled parents') % project_id) + + def _assert_whole_subtree_is_disabled(self, project_id): + subtree_list = self.driver.list_projects_in_subtree(project_id) + for ref in subtree_list: + if ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot disable project %s since ' + 'its subtree contains enabled ' + 'projects') % project_id) + + @notifications.updated(_PROJECT) + def update_project(self, tenant_id, tenant): + original_tenant = self.driver.get_project(tenant_id) + tenant = tenant.copy() + + parent_id = original_tenant.get('parent_id') + if 'parent_id' in tenant and tenant.get('parent_id') != parent_id: + raise exception.ForbiddenAction( + action=_('Update of `parent_id` is not allowed.')) + + if 'enabled' in tenant: + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + + # NOTE(rodrigods): for the current implementation we only allow to + # disable a project if all projects below it in the hierarchy are + # already disabled. This also means that we can not enable a + # project that has disabled parents. + original_tenant_enabled = original_tenant.get('enabled', True) + tenant_enabled = tenant.get('enabled', True) + if not original_tenant_enabled and tenant_enabled: + self._assert_all_parents_are_enabled(tenant_id) + if original_tenant_enabled and not tenant_enabled: + self._assert_whole_subtree_is_disabled(tenant_id) + self._disable_project(tenant_id) + + ret = self.driver.update_project(tenant_id, tenant) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, original_tenant['name'], + original_tenant['domain_id']) + return ret + + @notifications.deleted(_PROJECT) + def delete_project(self, tenant_id): + if not self.driver.is_leaf_project(tenant_id): + raise exception.ForbiddenAction( + action=_('cannot delete the project %s since it is not ' + 'a leaf in the hierarchy.') % tenant_id) + + project = self.driver.get_project(tenant_id) + project_user_ids = ( + self.assignment_api.list_user_ids_for_project(tenant_id)) + for user_id in project_user_ids: + payload = {'user_id': user_id, 'project_id': tenant_id} + self._emit_invalidate_user_project_tokens_notification(payload) + ret = self.driver.delete_project(tenant_id) + self.assignment_api.delete_project_assignments(tenant_id) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, project['name'], + project['domain_id']) + self.credential_api.delete_credentials_for_project(tenant_id) + return ret + + def _filter_projects_list(self, projects_list, user_id): + user_projects = self.assignment_api.list_projects_for_user(user_id) + user_projects_ids = set([proj['id'] for proj in user_projects]) + # Keep only the projects present in user_projects + projects_list = [proj for proj in projects_list + if proj['id'] in user_projects_ids] + + def list_project_parents(self, project_id, user_id=None): + parents = self.driver.list_project_parents(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(parents, user_id) + return parents + + def list_projects_in_subtree(self, project_id, user_id=None): + subtree = self.driver.list_projects_in_subtree(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(subtree, user_id) + return subtree + + @cache.on_arguments(should_cache_fn=SHOULD_CACHE, + expiration_time=EXPIRATION_TIME) + def get_domain(self, domain_id): + return self.driver.get_domain(domain_id) + + @cache.on_arguments(should_cache_fn=SHOULD_CACHE, + expiration_time=EXPIRATION_TIME) + def get_domain_by_name(self, domain_name): + return self.driver.get_domain_by_name(domain_name) + + @notifications.created('domain') + def create_domain(self, domain_id, domain): + if (not self.identity_api.multiple_domains_supported and + domain_id != CONF.identity.default_domain_id): + raise exception.Forbidden(_('Multiple domains are not supported')) + domain.setdefault('enabled', True) + domain['enabled'] = clean.domain_enabled(domain['enabled']) + ret = self.driver.create_domain(domain_id, domain) + if SHOULD_CACHE(ret): + self.get_domain.set(ret, self, domain_id) + self.get_domain_by_name.set(ret, self, ret['name']) + return ret + + @manager.response_truncated + def list_domains(self, hints=None): + return self.driver.list_domains(hints or driver_hints.Hints()) + + @notifications.disabled('domain', public=False) + def _disable_domain(self, domain_id): + """Emit a notification to the callback system domain is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + domain scoped tokens) when a domain is disabled. + + :param domain_id: domain identifier + :type domain_id: string + """ + pass + + @notifications.updated('domain') + def update_domain(self, 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) + # disable owned users & projects when the API user specifically set + # enabled=False + if (original_domain.get('enabled', True) and + not domain.get('enabled', True)): + self._disable_domain(domain_id) + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, original_domain['name']) + return ret + + @notifications.deleted('domain') + def delete_domain(self, domain_id): + # explicitly forbid deleting the default domain (this should be a + # carefully orchestrated manual process involving configuration + # changes, etc) + if domain_id == CONF.identity.default_domain_id: + raise exception.ForbiddenAction(action=_('delete the default ' + 'domain')) + + domain = self.driver.get_domain(domain_id) + + # To help avoid inadvertent deletes, we insist that the domain + # has been previously disabled. This also prevents a user deleting + # their own domain since, once it is disabled, they won't be able + # to get a valid token to issue this delete. + if domain['enabled']: + raise exception.ForbiddenAction( + action=_('cannot delete a domain that is enabled, ' + 'please disable it first.')) + + self._delete_domain_contents(domain_id) + # TODO(henry-nash): Although the controller will ensure deletion of + # all users & groups within the domain (which will cause all + # assignments for those users/groups to also be deleted), there + # could still be assignments on this domain for users/groups in + # other domains - so we should delete these here by making a call + # to the backend to delete all assignments for this domain. + # (see Bug #1277847) + self.driver.delete_domain(domain_id) + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, domain['name']) + + def _delete_domain_contents(self, domain_id): + """Delete the contents of a domain. + + Before we delete a domain, we need to remove all the entities + that are owned by it, i.e. Users, Groups & Projects. To do this we + call the respective delete functions for these entities, which are + themselves responsible for deleting any credentials and role grants + associated with them as well as revoking any relevant tokens. + + The order we delete entities is also important since some types + of backend may need to maintain referential integrity + throughout, and many of the entities have relationship with each + other. The following deletion order is therefore used: + + Projects: Reference user and groups for grants + Groups: Reference users for membership and domains for grants + Users: Reference domains for grants + + """ + + def _delete_projects(project, projects, examined): + if project['id'] in examined: + msg = _LE('Circular reference or a repeated entry found ' + 'projects hierarchy - %(project_id)s.') + LOG.error(msg, {'project_id': project['id']}) + return + + examined.add(project['id']) + children = [proj for proj in projects + if proj.get('parent_id') == project['id']] + for proj in children: + _delete_projects(proj, projects, examined) + + try: + self.delete_project(project['id']) + except exception.ProjectNotFound: + LOG.debug(('Project %(projectid)s not found when ' + 'deleting domain contents for %(domainid)s, ' + 'continuing with cleanup.'), + {'projectid': project['id'], + 'domainid': domain_id}) + + user_refs = self.identity_api.list_users(domain_scope=domain_id) + proj_refs = self.list_projects_in_domain(domain_id) + group_refs = self.identity_api.list_groups(domain_scope=domain_id) + + # Deleting projects recursively + roots = [x for x in proj_refs if x.get('parent_id') is None] + examined = set() + for project in roots: + _delete_projects(project, proj_refs, examined) + + for group in group_refs: + # Cleanup any existing groups. + if group['domain_id'] == domain_id: + try: + self.identity_api.delete_group(group['id']) + except exception.GroupNotFound: + LOG.debug(('Group %(groupid)s not found when deleting ' + 'domain contents for %(domainid)s, continuing ' + 'with cleanup.'), + {'groupid': group['id'], 'domainid': domain_id}) + + # And finally, delete the users themselves + for user in user_refs: + if user['domain_id'] == domain_id: + try: + self.identity_api.delete_user(user['id']) + except exception.UserNotFound: + LOG.debug(('User %(userid)s not found when ' + 'deleting domain contents for %(domainid)s, ' + 'continuing with cleanup.'), + {'userid': user['id'], + 'domainid': domain_id}) + + @manager.response_truncated + def list_projects(self, hints=None): + return self.driver.list_projects(hints or driver_hints.Hints()) + + # NOTE(henry-nash): list_projects_in_domain is actually an internal method + # and not exposed via the API. Therefore there is no need to support + # driver hints for it. + def list_projects_in_domain(self, domain_id): + return self.driver.list_projects_in_domain(domain_id) + + @cache.on_arguments(should_cache_fn=SHOULD_CACHE, + expiration_time=EXPIRATION_TIME) + def get_project(self, project_id): + return self.driver.get_project(project_id) + + @cache.on_arguments(should_cache_fn=SHOULD_CACHE, + expiration_time=EXPIRATION_TIME) + def get_project_by_name(self, tenant_name, domain_id): + return self.driver.get_project_by_name(tenant_name, domain_id) + + @notifications.internal( + notifications.INVALIDATE_USER_PROJECT_TOKEN_PERSISTENCE) + def _emit_invalidate_user_project_tokens_notification(self, payload): + # This notification's payload is a dict of user_id and + # project_id so the token provider can invalidate the tokens + # from persistence if persistence is enabled. + pass + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + + def _get_list_limit(self): + return CONF.resource.list_limit or CONF.list_limit + + @abc.abstractmethod + def get_project_by_name(self, tenant_name, domain_id): + """Get a tenant by name. + + :returns: tenant_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # domain crud + @abc.abstractmethod + def create_domain(self, domain_id, domain): + """Creates a new domain. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains(self, hints): + """List domains in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of domain_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains_from_ids(self, domain_ids): + """List domains for the provided list of ids. + + :param domain_ids: list of ids + + :returns: a list of domain_refs. + + This method is used internally by the assignment manager to bulk read + a set of domains given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain(self, domain_id): + """Get a domain by ID. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain_by_name(self, domain_name): + """Get a domain by name. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_domain(self, domain_id, domain): + """Updates an existing domain. + + :raises: keystone.exception.DomainNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_domain(self, domain_id): + """Deletes an existing domain. + + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # project crud + @abc.abstractmethod + def create_project(self, project_id, project): + """Creates a new project. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects(self, hints): + """List projects in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_from_ids(self, project_ids): + """List projects for the provided list of ids. + + :param project_ids: list of ids + + :returns: a list of project_refs. + + This method is used internally by the assignment manager to bulk read + a set of projects given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_ids_from_domain_ids(self, domain_ids): + """List project ids for the provided list of domain ids. + + :param domain_ids: list of domain ids + + :returns: a list of project ids owned by the specified domain ids. + + This method is used internally by the assignment manager to bulk read + a set of project ids given a list of domain ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_in_domain(self, domain_id): + """List projects in the domain. + + :param domain_id: the driver MUST only return projects + within this domain. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_project(self, project_id): + """Get a project by ID. + + :returns: project_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_project(self, project_id, project): + """Updates an existing project. + + :raises: keystone.exception.ProjectNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_project(self, project_id): + """Deletes an existing project. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_parents(self, project_id): + """List all parents from a project by its ID. + + :param project_id: the driver will list the parents of this + project. + + :returns: a list of project_refs or an empty list. + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def list_projects_in_subtree(self, project_id): + """List all projects in the subtree below the hierarchy of the + given project. + + :param project_id: the driver will get the subtree under + this project. + + :returns: a list of project_refs or an empty list + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def is_leaf_project(self, project_id): + """Checks if a project is a leaf in the hierarchy. + + :param project_id: the driver will check if this project + is a leaf in the hierarchy. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + # Domain management functions for backends that only allow a single + # domain. Currently, this is only LDAP, but might be used by other + # backends in the future. + def _set_default_domain(self, ref): + """If the domain ID has not been set, set it to the default.""" + if isinstance(ref, dict): + if 'domain_id' not in ref: + ref = ref.copy() + ref['domain_id'] = CONF.identity.default_domain_id + return ref + elif isinstance(ref, list): + return [self._set_default_domain(x) for x in ref] + else: + raise ValueError(_('Expected dict or list: %s') % type(ref)) + + def _validate_default_domain(self, ref): + """Validate that either the default domain or nothing is specified. + + Also removes the domain from the ref so that LDAP doesn't have to + persist the attribute. + + """ + ref = ref.copy() + domain_id = ref.pop('domain_id', CONF.identity.default_domain_id) + self._validate_default_domain_id(domain_id) + return ref + + def _validate_default_domain_id(self, domain_id): + """Validate that the domain ID specified belongs to the default domain. + + """ + if domain_id != CONF.identity.default_domain_id: + raise exception.DomainNotFound(domain_id=domain_id) diff --git a/keystone/tests/test_backend.py b/keystone/tests/test_backend.py index 013b3927f5..9e6be02d81 100644 --- a/keystone/tests/test_backend.py +++ b/keystone/tests/test_backend.py @@ -3071,7 +3071,7 @@ class IdentityTests(object): user_projects = self.assignment_api.list_projects_for_user(user1['id']) self.assertEqual(3, len(user_projects)) - @tests.skip_if_cache_disabled('assignment') + @tests.skip_if_cache_disabled('resource') @tests.skip_if_no_multiple_domains_support def test_domain_rename_invalidates_get_domain_by_name_cache(self): domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, @@ -3086,7 +3086,7 @@ class IdentityTests(object): self.assignment_api.get_domain_by_name, domain_name) - @tests.skip_if_cache_disabled('assignment') + @tests.skip_if_cache_disabled('resource') def test_cache_layer_domain_crud(self): domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, 'enabled': True} @@ -3096,14 +3096,14 @@ class IdentityTests(object): domain_ref = self.assignment_api.get_domain(domain_id) updated_domain_ref = copy.deepcopy(domain_ref) updated_domain_ref['name'] = uuid.uuid4().hex - # Update domain, bypassing assignment api manager - self.assignment_api.driver.update_domain(domain_id, updated_domain_ref) + # Update domain, bypassing resource api manager + self.resource_api.driver.update_domain(domain_id, updated_domain_ref) # Verify get_domain still returns the domain self.assertDictContainsSubset( domain_ref, self.assignment_api.get_domain(domain_id)) # Invalidate cache - self.assignment_api.get_domain.invalidate(self.assignment_api, - domain_id) + self.resource_api.get_domain.invalidate(self.resource_api, + domain_id) # Verify get_domain returns the updated domain self.assertDictContainsSubset( updated_domain_ref, self.assignment_api.get_domain(domain_id)) @@ -3112,28 +3112,28 @@ class IdentityTests(object): self.assignment_api.update_domain(domain_id, domain_ref) self.assertDictContainsSubset( domain_ref, self.assignment_api.get_domain(domain_id)) - # Make sure domain is 'disabled', bypass assignment api manager + # Make sure domain is 'disabled', bypass resource api manager domain_ref_disabled = domain_ref.copy() domain_ref_disabled['enabled'] = False - self.assignment_api.driver.update_domain(domain_id, - domain_ref_disabled) - # Delete domain, bypassing assignment api manager - self.assignment_api.driver.delete_domain(domain_id) + self.resource_api.driver.update_domain(domain_id, + domain_ref_disabled) + # Delete domain, bypassing resource api manager + self.resource_api.driver.delete_domain(domain_id) # Verify get_domain still returns the domain self.assertDictContainsSubset( domain_ref, self.assignment_api.get_domain(domain_id)) # Invalidate cache - self.assignment_api.get_domain.invalidate(self.assignment_api, - domain_id) + self.resource_api.get_domain.invalidate(self.resource_api, + domain_id) # Verify get_domain now raises DomainNotFound self.assertRaises(exception.DomainNotFound, self.assignment_api.get_domain, domain_id) # Recreate Domain self.assignment_api.create_domain(domain_id, domain) self.assignment_api.get_domain(domain_id) - # Make sure domain is 'disabled', bypass assignment api manager + # Make sure domain is 'disabled', bypass resource api manager domain['enabled'] = False - self.assignment_api.driver.update_domain(domain_id, domain) + self.resource_api.driver.update_domain(domain_id, domain) # Delete domain self.assignment_api.delete_domain(domain_id) # verify DomainNotFound raised @@ -3141,7 +3141,7 @@ class IdentityTests(object): self.assignment_api.get_domain, domain_id) - @tests.skip_if_cache_disabled('assignment') + @tests.skip_if_cache_disabled('resource') @tests.skip_if_no_multiple_domains_support def test_project_rename_invalidates_get_project_by_name_cache(self): domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, @@ -3161,7 +3161,7 @@ class IdentityTests(object): project_name, domain['id']) - @tests.skip_if_cache_disabled('assignment') + @tests.skip_if_cache_disabled('resource') @tests.skip_if_no_multiple_domains_support def test_cache_layer_project_crud(self): domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, @@ -3175,15 +3175,15 @@ class IdentityTests(object): self.assignment_api.get_project(project_id) updated_project = copy.deepcopy(project) updated_project['name'] = uuid.uuid4().hex - # Update project, bypassing assignment_api manager - self.assignment_api.driver.update_project(project_id, - updated_project) + # Update project, bypassing resource manager + self.resource_api.driver.update_project(project_id, + updated_project) # Verify get_project still returns the original project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) # Invalidate cache - self.assignment_api.get_project.invalidate(self.assignment_api, - project_id) + self.resource_api.get_project.invalidate(self.resource_api, + project_id) # Verify get_project now returns the new project self.assertDictContainsSubset( updated_project, @@ -3193,14 +3193,14 @@ class IdentityTests(object): # Verify get_project returns the original project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) - # Delete project bypassing assignment_api - self.assignment_api.driver.delete_project(project_id) + # Delete project bypassing resource + self.resource_api.driver.delete_project(project_id) # Verify get_project still returns the project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) # Invalidate cache - self.assignment_api.get_project.invalidate(self.assignment_api, - project_id) + self.resource_api.get_project.invalidate(self.resource_api, + project_id) # Verify ProjectNotFound now raised self.assertRaises(exception.ProjectNotFound, self.assignment_api.get_project, @@ -5486,7 +5486,7 @@ class LimitTests(filtering.FilterTests): # Override with driver specific limit if entity == 'project': - self.config_fixture.config(group='assignment', list_limit=5) + self.config_fixture.config(group='resource', list_limit=5) else: self.config_fixture.config(group='identity', list_limit=5) diff --git a/keystone/tests/test_backend_ldap.py b/keystone/tests/test_backend_ldap.py index 3669a05058..b1a0c1cf03 100644 --- a/keystone/tests/test_backend_ldap.py +++ b/keystone/tests/test_backend_ldap.py @@ -21,7 +21,6 @@ import ldap import mock from testtools import matchers -from keystone import assignment from keystone.common import cache from keystone.common import ldap as common_ldap from keystone.common.ldap import core as common_ldap_core @@ -30,6 +29,7 @@ from keystone import config from keystone import exception from keystone import identity from keystone.identity.mapping_backends import mapping as map +from keystone import resource from keystone import tests from keystone.tests import default_fixtures from keystone.tests import fakeldap @@ -585,7 +585,7 @@ class BaseLDAPIdentity(test_backend.IdentityTests): def test_list_domains(self): domains = self.assignment_api.list_domains() self.assertEqual( - [assignment.calc_default_domain()], + [resource.calc_default_domain()], domains) def test_list_domains_non_default_domain_id(self): @@ -999,8 +999,8 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): self.role_api.get_role.invalidate(self.assignment_api, self.role_member['id']) self.role_api.get_role(self.role_member['id']) - self.assignment_api.get_project.invalidate(self.assignment_api, - self.tenant_bar['id']) + self.resource_api.get_project.invalidate(self.resource_api, + self.tenant_bar['id']) self.assertRaises(exception.ProjectNotFound, self.assignment_api.get_project, self.tenant_bar['id']) @@ -1050,8 +1050,8 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): # could affect what the drivers would return up to the manager. This # solves this assumption when working with aggressive (on-create) # cache population. - self.assignment_api.get_project.invalidate(self.assignment_api, - self.tenant_baz['id']) + self.resource_api.get_project.invalidate(self.resource_api, + self.tenant_baz['id']) tenant_ref = self.assignment_api.get_project(self.tenant_baz['id']) self.assertEqual(self.tenant_baz['id'], tenant_ref['id']) self.assertEqual(self.tenant_baz['name'], tenant_ref['name']) @@ -1072,8 +1072,8 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): # could affect what the drivers would return up to the manager. This # solves this assumption when working with aggressive (on-create) # cache population. - self.assignment_api.get_project.invalidate(self.assignment_api, - self.tenant_baz['id']) + self.resource_api.get_project.invalidate(self.resource_api, + self.tenant_baz['id']) tenant_ref = self.assignment_api.get_project(self.tenant_baz['id']) self.assertEqual(self.tenant_baz['id'], tenant_ref['id']) self.assertEqual(self.tenant_baz['description'], tenant_ref['name']) @@ -1093,8 +1093,8 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): # that could affect what the drivers would return up to the manager. # This solves this assumption when working with aggressive (on-create) # cache population. - self.assignment_api.get_project.invalidate(self.assignment_api, - self.tenant_baz['id']) + self.resource_api.get_project.invalidate(self.resource_api, + self.tenant_baz['id']) tenant_ref = self.assignment_api.get_project(self.tenant_baz['id']) self.assertEqual(self.tenant_baz['id'], tenant_ref['id']) self.assertNotIn('name', tenant_ref) @@ -1566,15 +1566,15 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): self.assignment_api.get_project(project_id) updated_project = copy.deepcopy(project) updated_project['description'] = uuid.uuid4().hex - # Update project, bypassing assignment_api manager - self.assignment_api.driver.update_project(project_id, - updated_project) + # Update project, bypassing resource manager + self.resource_api.driver.update_project(project_id, + updated_project) # Verify get_project still returns the original project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) # Invalidate cache - self.assignment_api.get_project.invalidate(self.assignment_api, - project_id) + self.resource_api.get_project.invalidate(self.resource_api, + project_id) # Verify get_project now returns the new project self.assertDictContainsSubset( updated_project, @@ -1584,14 +1584,14 @@ class LDAPIdentity(BaseLDAPIdentity, tests.TestCase): # Verify get_project returns the original project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) - # Delete project bypassing assignment_api - self.assignment_api.driver.delete_project(project_id) + # Delete project bypassing resource_api + self.resource_api.driver.delete_project(project_id) # Verify get_project still returns the project_ref self.assertDictContainsSubset( project, self.assignment_api.get_project(project_id)) # Invalidate cache - self.assignment_api.get_project.invalidate(self.assignment_api, - project_id) + self.resource_api.get_project.invalidate(self.resource_api, + project_id) # Verify ProjectNotFound now raised self.assertRaises(exception.ProjectNotFound, self.assignment_api.get_project, @@ -2115,6 +2115,9 @@ class LdapIdentitySqlAssignment(BaseLDAPIdentity, tests.SQLDriverOverrides, self.config_fixture.config( group='identity', driver='keystone.identity.backends.ldap.Identity') + self.config_fixture.config( + group='resource', + driver='keystone.resource.backends.sql.Resource') self.config_fixture.config( group='assignment', driver='keystone.assignment.backends.sql.Assignment') @@ -2124,7 +2127,7 @@ class LdapIdentitySqlAssignment(BaseLDAPIdentity, tests.SQLDriverOverrides, def test_list_domains(self): domains = self.assignment_api.list_domains() - self.assertEqual([assignment.calc_default_domain()], domains) + self.assertEqual([resource.calc_default_domain()], domains) def test_list_domains_non_default_domain_id(self): # If change the default_domain_id, the ID of the default domain @@ -2340,7 +2343,7 @@ class BaseMultiLDAPandSQLIdentity(object): self.domains[domain] = create_domain( {'id': uuid.uuid4().hex, 'name': domain}) self.domains['domain_default'] = create_domain( - assignment.calc_default_domain()) + resource.calc_default_domain()) def test_authenticate_to_each_domain(self): """Test that a user in each domain can authenticate.""" @@ -2408,6 +2411,9 @@ class MultiLDAPandSQLIdentity(BaseLDAPIdentity, tests.SQLDriverOverrides, self.config_fixture.config( group='identity', driver='keystone.identity.backends.sql.Identity') + self.config_fixture.config( + group='resource', + driver='keystone.resource.backends.sql.Resource') self.config_fixture.config( group='assignment', driver='keystone.assignment.backends.sql.Assignment') @@ -2672,8 +2678,11 @@ class DomainSpecificLDAPandSQLIdentity( def config_overrides(self): super(DomainSpecificLDAPandSQLIdentity, self).config_overrides() - # Make sure assignment is actually an SQL driver, + # Make sure resource & assignment are actually SQL drivers, # BaseLDAPIdentity causes this option to use LDAP. + self.config_fixture.config( + group='resource', + driver='keystone.resource.backends.sql.Resource') self.config_fixture.config( group='assignment', driver='keystone.assignment.backends.sql.Assignment') @@ -2833,6 +2842,9 @@ class DomainSpecificSQLIdentity(DomainSpecificLDAPandSQLIdentity): self.config_fixture.config( group='identity', driver='keystone.identity.backends.ldap.Identity') + self.config_fixture.config( + group='resource', + driver='keystone.resource.backends.sql.Resource') self.config_fixture.config( group='assignment', driver='keystone.assignment.backends.sql.Assignment') diff --git a/keystone/tests/test_backend_sql.py b/keystone/tests/test_backend_sql.py index a5d346241a..25c57c0972 100644 --- a/keystone/tests/test_backend_sql.py +++ b/keystone/tests/test_backend_sql.py @@ -838,3 +838,35 @@ class DeprecatedDecorators(SqlTests): self.assertRaises(logging.DeprecatedConfig, self.assignment_api.create_role, role_ref['id'], role_ref) + + def test_assignment_to_resource_api(self): + """Test that calling one of the methods does call LOG.deprecated. + + This method is really generic to the type of backend, but we need + one to execute the test, so the SQL backend is as good as any. + + """ + + # Rather than try and check that a log message is issued, we + # enable fatal_deprecations so that we can check for the + # raising of the exception. + + # First try to create a project without enabling fatal deprecations, + # which should work due to the cross manager deprecated calls. + project_ref = { + 'id': uuid.uuid4().hex, + 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID} + self.assignment_api.create_project(project_ref['id'], project_ref) + self.resource_api.get_project(project_ref['id']) + + # Now enable fatal exceptions - creating a project by calling the + # old manager should now fail. + self.config_fixture.config(fatal_deprecations=True) + project_ref = { + 'id': uuid.uuid4().hex, + 'name': uuid.uuid4().hex, + 'domain_id': DEFAULT_DOMAIN_ID} + self.assertRaises(logging.DeprecatedConfig, + self.assignment_api.create_project, + project_ref['id'], project_ref) diff --git a/keystone/tests/test_v3_filters.py b/keystone/tests/test_v3_filters.py index 072ba5f4ef..d9a54e5038 100644 --- a/keystone/tests/test_v3_filters.py +++ b/keystone/tests/test_v3_filters.py @@ -400,7 +400,7 @@ class IdentityTestListLimitCase(IdentityTestFilteredCase): self._test_entity_list_limit('group', 'identity') def test_projects_list_limit(self): - self._test_entity_list_limit('project', 'assignment') + self._test_entity_list_limit('project', 'resource') def test_services_list_limit(self): self._test_entity_list_limit('service', 'catalog')