diff options
author | WuKong <rebirthmonkey@gmail.com> | 2015-06-30 18:47:29 +0200 |
---|---|---|
committer | WuKong <rebirthmonkey@gmail.com> | 2015-06-30 18:47:29 +0200 |
commit | b8c756ecdd7cced1db4300935484e8c83701c82e (patch) | |
tree | 87e51107d82b217ede145de9d9d59e2100725bd7 /keystone-moon/keystone/resource | |
parent | c304c773bae68fb854ed9eab8fb35c4ef17cf136 (diff) |
migrate moon code from github to opnfv
Change-Id: Ice53e368fd1114d56a75271aa9f2e598e3eba604
Signed-off-by: WuKong <rebirthmonkey@gmail.com>
Diffstat (limited to 'keystone-moon/keystone/resource')
-rw-r--r-- | keystone-moon/keystone/resource/__init__.py | 15 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/backends/__init__.py | 0 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/backends/ldap.py | 196 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/backends/sql.py | 260 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/config_backends/__init__.py | 0 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/config_backends/sql.py | 119 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/controllers.py | 281 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/core.py | 1324 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/routers.py | 94 | ||||
-rw-r--r-- | keystone-moon/keystone/resource/schema.py | 75 |
10 files changed, 2364 insertions, 0 deletions
diff --git a/keystone-moon/keystone/resource/__init__.py b/keystone-moon/keystone/resource/__init__.py new file mode 100644 index 00000000..c0070a12 --- /dev/null +++ b/keystone-moon/keystone/resource/__init__.py @@ -0,0 +1,15 @@ +# 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 import controllers # noqa +from keystone.resource.core import * # noqa +from keystone.resource import routers # noqa diff --git a/keystone-moon/keystone/resource/backends/__init__.py b/keystone-moon/keystone/resource/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/resource/backends/__init__.py diff --git a/keystone-moon/keystone/resource/backends/ldap.py b/keystone-moon/keystone/resource/backends/ldap.py new file mode 100644 index 00000000..434c2b04 --- /dev/null +++ b/keystone-moon/keystone/resource/backends/ldap.py @@ -0,0 +1,196 @@ +# 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 oslo_config import cfg +from oslo_log import log + +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 exception +from keystone.i18n import _ +from keystone.identity.backends import ldap as ldap_identity +from keystone import resource + + +CONF = cfg.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. + # This is safe to do since if you are using LDAP for resource, it is + # required that 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_filtered(hints)) + + 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) + + def get_all_filtered(self, hints): + query = self.filter_query(hints) + return super(ProjectApi, self).get_all(query) diff --git a/keystone-moon/keystone/resource/backends/sql.py b/keystone-moon/keystone/resource/backends/sql.py new file mode 100644 index 00000000..fb117240 --- /dev/null +++ b/keystone-moon/keystone/resource/backends/sql.py @@ -0,0 +1,260 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_config import cfg +from oslo_log import log + +from keystone import clean +from keystone.common import sql +from keystone import exception +from keystone.i18n import _LE +from keystone import resource as keystone_resource + + +CONF = cfg.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-moon/keystone/resource/config_backends/__init__.py b/keystone-moon/keystone/resource/config_backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/resource/config_backends/__init__.py diff --git a/keystone-moon/keystone/resource/config_backends/sql.py b/keystone-moon/keystone/resource/config_backends/sql.py new file mode 100644 index 00000000..e54bf22b --- /dev/null +++ b/keystone-moon/keystone/resource/config_backends/sql.py @@ -0,0 +1,119 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystone.common import sql +from keystone import exception +from keystone.i18n import _ +from keystone import resource + + +class WhiteListedConfig(sql.ModelBase, sql.ModelDictMixin): + __tablename__ = 'whitelisted_config' + domain_id = sql.Column(sql.String(64), primary_key=True) + group = sql.Column(sql.String(255), primary_key=True) + option = sql.Column(sql.String(255), primary_key=True) + value = sql.Column(sql.JsonBlob(), nullable=False) + + def to_dict(self): + d = super(WhiteListedConfig, self).to_dict() + d.pop('domain_id') + return d + + +class SensitiveConfig(sql.ModelBase, sql.ModelDictMixin): + __tablename__ = 'sensitive_config' + domain_id = sql.Column(sql.String(64), primary_key=True) + group = sql.Column(sql.String(255), primary_key=True) + option = sql.Column(sql.String(255), primary_key=True) + value = sql.Column(sql.JsonBlob(), nullable=False) + + def to_dict(self): + d = super(SensitiveConfig, self).to_dict() + d.pop('domain_id') + return d + + +class DomainConfig(resource.DomainConfigDriver): + + def choose_table(self, sensitive): + if sensitive: + return SensitiveConfig + else: + return WhiteListedConfig + + @sql.handle_conflicts(conflict_type='domain_config') + def create_config_option(self, domain_id, group, option, value, + sensitive=False): + with sql.transaction() as session: + config_table = self.choose_table(sensitive) + ref = config_table(domain_id=domain_id, group=group, + option=option, value=value) + session.add(ref) + return ref.to_dict() + + def _get_config_option(self, session, domain_id, group, option, sensitive): + try: + config_table = self.choose_table(sensitive) + ref = (session.query(config_table). + filter_by(domain_id=domain_id, group=group, + option=option).one()) + except sql.NotFound: + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + return ref + + def get_config_option(self, domain_id, group, option, sensitive=False): + with sql.transaction() as session: + ref = self._get_config_option(session, domain_id, group, option, + sensitive) + return ref.to_dict() + + def list_config_options(self, domain_id, group=None, option=None, + sensitive=False): + with sql.transaction() as session: + config_table = self.choose_table(sensitive) + query = session.query(config_table) + query = query.filter_by(domain_id=domain_id) + if group: + query = query.filter_by(group=group) + if option: + query = query.filter_by(option=option) + return [ref.to_dict() for ref in query.all()] + + def update_config_option(self, domain_id, group, option, value, + sensitive=False): + with sql.transaction() as session: + ref = self._get_config_option(session, domain_id, group, option, + sensitive) + ref.value = value + return ref.to_dict() + + def delete_config_options(self, domain_id, group=None, option=None, + sensitive=False): + """Deletes config options that match the filter parameters. + + Since the public API is broken down into calls for delete in both the + whitelisted and sensitive methods, we are silent at the driver level + if there was nothing to delete. + + """ + with sql.transaction() as session: + config_table = self.choose_table(sensitive) + query = session.query(config_table) + query = query.filter_by(domain_id=domain_id) + if group: + query = query.filter_by(group=group) + if option: + query = query.filter_by(option=option) + query.delete(False) diff --git a/keystone-moon/keystone/resource/controllers.py b/keystone-moon/keystone/resource/controllers.py new file mode 100644 index 00000000..886b5eb1 --- /dev/null +++ b/keystone-moon/keystone/resource/controllers.py @@ -0,0 +1,281 @@ +# Copyright 2013 Metacloud, Inc. +# Copyright 2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Workflow Logic the Resource service.""" + +import uuid + +from oslo_config import cfg +from oslo_log import log + +from keystone.common import controller +from keystone.common import dependency +from keystone.common import validation +from keystone.common import wsgi +from keystone import exception +from keystone.i18n import _ +from keystone import notifications +from keystone.resource import schema + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +@dependency.requires('resource_api') +class Tenant(controller.V2Controller): + + @controller.v2_deprecated + def get_all_projects(self, context, **kw): + """Gets a list of all tenants for an admin user.""" + if 'name' in context['query_string']: + return self.get_project_by_name( + context, context['query_string'].get('name')) + + self.assert_admin(context) + tenant_refs = self.resource_api.list_projects_in_domain( + CONF.identity.default_domain_id) + for tenant_ref in tenant_refs: + tenant_ref = self.filter_domain_id(tenant_ref) + params = { + 'limit': context['query_string'].get('limit'), + 'marker': context['query_string'].get('marker'), + } + return self.format_project_list(tenant_refs, **params) + + @controller.v2_deprecated + def get_project(self, context, tenant_id): + # TODO(termie): this stuff should probably be moved to middleware + self.assert_admin(context) + ref = self.resource_api.get_project(tenant_id) + return {'tenant': self.filter_domain_id(ref)} + + @controller.v2_deprecated + def get_project_by_name(self, context, tenant_name): + self.assert_admin(context) + ref = self.resource_api.get_project_by_name( + tenant_name, CONF.identity.default_domain_id) + return {'tenant': self.filter_domain_id(ref)} + + # CRUD Extension + @controller.v2_deprecated + def create_project(self, context, tenant): + tenant_ref = self._normalize_dict(tenant) + + if 'name' not in tenant_ref or not tenant_ref['name']: + msg = _('Name field is required and cannot be empty') + raise exception.ValidationError(message=msg) + + self.assert_admin(context) + tenant_ref['id'] = tenant_ref.get('id', uuid.uuid4().hex) + tenant = self.resource_api.create_project( + tenant_ref['id'], + self._normalize_domain_id(context, tenant_ref)) + return {'tenant': self.filter_domain_id(tenant)} + + @controller.v2_deprecated + def update_project(self, context, tenant_id, tenant): + self.assert_admin(context) + # Remove domain_id if specified - a v2 api caller should not + # be specifying that + clean_tenant = tenant.copy() + clean_tenant.pop('domain_id', None) + + tenant_ref = self.resource_api.update_project( + tenant_id, clean_tenant) + return {'tenant': tenant_ref} + + @controller.v2_deprecated + def delete_project(self, context, tenant_id): + self.assert_admin(context) + self.resource_api.delete_project(tenant_id) + + +@dependency.requires('resource_api') +class DomainV3(controller.V3Controller): + collection_name = 'domains' + member_name = 'domain' + + def __init__(self): + super(DomainV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_domain + + @controller.protected() + @validation.validated(schema.domain_create, 'domain') + def create_domain(self, context, domain): + ref = self._assign_unique_id(self._normalize_dict(domain)) + initiator = notifications._get_request_audit_info(context) + ref = self.resource_api.create_domain(ref['id'], ref, initiator) + return DomainV3.wrap_member(context, ref) + + @controller.filterprotected('enabled', 'name') + def list_domains(self, context, filters): + hints = DomainV3.build_driver_hints(context, filters) + refs = self.resource_api.list_domains(hints=hints) + return DomainV3.wrap_collection(context, refs, hints=hints) + + @controller.protected() + def get_domain(self, context, domain_id): + ref = self.resource_api.get_domain(domain_id) + return DomainV3.wrap_member(context, ref) + + @controller.protected() + @validation.validated(schema.domain_update, 'domain') + def update_domain(self, context, domain_id, domain): + self._require_matching_id(domain_id, domain) + initiator = notifications._get_request_audit_info(context) + ref = self.resource_api.update_domain(domain_id, domain, initiator) + return DomainV3.wrap_member(context, ref) + + @controller.protected() + def delete_domain(self, context, domain_id): + initiator = notifications._get_request_audit_info(context) + return self.resource_api.delete_domain(domain_id, initiator) + + +@dependency.requires('domain_config_api') +class DomainConfigV3(controller.V3Controller): + member_name = 'config' + + @controller.protected() + def create_domain_config(self, context, domain_id, config): + original_config = ( + self.domain_config_api.get_config_with_sensitive_info(domain_id)) + ref = self.domain_config_api.create_config(domain_id, config) + if original_config: + # Return status code 200, since config already existed + return wsgi.render_response(body={self.member_name: ref}) + else: + return wsgi.render_response(body={self.member_name: ref}, + status=('201', 'Created')) + + @controller.protected() + def get_domain_config(self, context, domain_id, group=None, option=None): + ref = self.domain_config_api.get_config(domain_id, group, option) + return {self.member_name: ref} + + @controller.protected() + def update_domain_config( + self, context, domain_id, config, group, option): + ref = self.domain_config_api.update_config( + domain_id, config, group, option) + return wsgi.render_response(body={self.member_name: ref}) + + def update_domain_config_group(self, context, domain_id, group, config): + return self.update_domain_config( + context, domain_id, config, group, option=None) + + def update_domain_config_only(self, context, domain_id, config): + return self.update_domain_config( + context, domain_id, config, group=None, option=None) + + @controller.protected() + def delete_domain_config( + self, context, domain_id, group=None, option=None): + self.domain_config_api.delete_config(domain_id, group, option) + + +@dependency.requires('resource_api') +class ProjectV3(controller.V3Controller): + collection_name = 'projects' + member_name = 'project' + + def __init__(self): + super(ProjectV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_project + + @controller.protected() + @validation.validated(schema.project_create, 'project') + def create_project(self, context, project): + ref = self._assign_unique_id(self._normalize_dict(project)) + ref = self._normalize_domain_id(context, ref) + initiator = notifications._get_request_audit_info(context) + ref = self.resource_api.create_project(ref['id'], ref, + initiator=initiator) + return ProjectV3.wrap_member(context, ref) + + @controller.filterprotected('domain_id', 'enabled', 'name', + 'parent_id') + def list_projects(self, context, filters): + hints = ProjectV3.build_driver_hints(context, filters) + refs = self.resource_api.list_projects(hints=hints) + return ProjectV3.wrap_collection(context, refs, hints=hints) + + def _expand_project_ref(self, context, ref): + params = context['query_string'] + + parents_as_list = 'parents_as_list' in params and ( + self.query_filter_is_true(params['parents_as_list'])) + parents_as_ids = 'parents_as_ids' in params and ( + self.query_filter_is_true(params['parents_as_ids'])) + + subtree_as_list = 'subtree_as_list' in params and ( + self.query_filter_is_true(params['subtree_as_list'])) + subtree_as_ids = 'subtree_as_ids' in params and ( + self.query_filter_is_true(params['subtree_as_ids'])) + + # parents_as_list and parents_as_ids are mutually exclusive + if parents_as_list and parents_as_ids: + msg = _('Cannot use parents_as_list and parents_as_ids query ' + 'params at the same time.') + raise exception.ValidationError(msg) + + # subtree_as_list and subtree_as_ids are mutually exclusive + if subtree_as_list and subtree_as_ids: + msg = _('Cannot use subtree_as_list and subtree_as_ids query ' + 'params at the same time.') + raise exception.ValidationError(msg) + + user_id = self.get_auth_context(context).get('user_id') + + if parents_as_list: + parents = self.resource_api.list_project_parents( + ref['id'], user_id) + ref['parents'] = [ProjectV3.wrap_member(context, p) + for p in parents] + elif parents_as_ids: + ref['parents'] = self.resource_api.get_project_parents_as_ids(ref) + + if subtree_as_list: + subtree = self.resource_api.list_projects_in_subtree( + ref['id'], user_id) + ref['subtree'] = [ProjectV3.wrap_member(context, p) + for p in subtree] + elif subtree_as_ids: + ref['subtree'] = self.resource_api.get_projects_in_subtree_as_ids( + ref['id']) + + @controller.protected() + def get_project(self, context, project_id): + ref = self.resource_api.get_project(project_id) + self._expand_project_ref(context, ref) + return ProjectV3.wrap_member(context, ref) + + @controller.protected() + @validation.validated(schema.project_update, 'project') + def update_project(self, context, project_id, project): + self._require_matching_id(project_id, project) + self._require_matching_domain_id( + project_id, project, self.resource_api.get_project) + initiator = notifications._get_request_audit_info(context) + ref = self.resource_api.update_project(project_id, project, + initiator=initiator) + return ProjectV3.wrap_member(context, ref) + + @controller.protected() + def delete_project(self, context, project_id): + initiator = notifications._get_request_audit_info(context) + return self.resource_api.delete_project(project_id, + initiator=initiator) diff --git a/keystone-moon/keystone/resource/core.py b/keystone-moon/keystone/resource/core.py new file mode 100644 index 00000000..017eb4e7 --- /dev/null +++ b/keystone-moon/keystone/resource/core.py @@ -0,0 +1,1324 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Main entry point into the resource service.""" + +import abc + +from oslo_config import cfg +from oslo_log import log +import six + +from keystone import clean +from keystone.common import cache +from keystone.common import dependency +from keystone.common import driver_hints +from keystone.common import manager +from keystone.contrib import federation +from keystone import exception +from keystone.i18n import _, _LE, _LW +from keystone import notifications + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) +MEMOIZE = cache.get_memoization_decorator(section='resource') + + +def calc_default_domain(): + return {'description': + (u'Owns users and tenants (i.e. projects)' + ' available on Identity API v2.'), + 'enabled': True, + 'id': CONF.identity.default_domain_id, + 'name': u'Default'} + + +@dependency.provider('resource_api') +@dependency.requires('assignment_api', 'credential_api', 'domain_config_api', + 'identity_api', 'revoke_api') +class Manager(manager.Manager): + """Default pivot point for the resource backend. + + See :mod:`keystone.common.manager.Manager` for more details on how this + dynamically calls the backend. + + """ + _DOMAIN = 'domain' + _PROJECT = 'project' + + def __init__(self): + # If there is a specific driver specified for resource, then use it. + # Otherwise retrieve the driver type from the assignment driver. + resource_driver = CONF.resource.driver + + if resource_driver is None: + assignment_driver = ( + dependency.get_provider('assignment_api').driver) + resource_driver = assignment_driver.default_resource_driver() + + super(Manager, self).__init__(resource_driver) + + def _get_hierarchy_depth(self, parents_list): + return len(parents_list) + 1 + + def _assert_max_hierarchy_depth(self, project_id, parents_list=None): + if parents_list is None: + parents_list = self.list_project_parents(project_id) + max_depth = CONF.max_project_tree_depth + if self._get_hierarchy_depth(parents_list) > max_depth: + raise exception.ForbiddenAction( + action=_('max hierarchy depth reached for ' + '%s branch.') % project_id) + + def create_project(self, tenant_id, tenant, initiator=None): + tenant = tenant.copy() + tenant.setdefault('enabled', True) + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + tenant.setdefault('description', '') + tenant.setdefault('parent_id', None) + + if tenant.get('parent_id') is not None: + parent_ref = self.get_project(tenant.get('parent_id')) + parents_list = self.list_project_parents(parent_ref['id']) + parents_list.append(parent_ref) + for ref in parents_list: + if ref.get('domain_id') != tenant.get('domain_id'): + raise exception.ForbiddenAction( + action=_('cannot create a project within a different ' + 'domain than its parents.')) + if not ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot create a project in a ' + 'branch containing a disabled ' + 'project: %s') % ref['id']) + self._assert_max_hierarchy_depth(tenant.get('parent_id'), + parents_list) + + ret = self.driver.create_project(tenant_id, tenant) + notifications.Audit.created(self._PROJECT, tenant_id, initiator) + if MEMOIZE.should_cache(ret): + self.get_project.set(ret, self, tenant_id) + self.get_project_by_name.set(ret, self, ret['name'], + ret['domain_id']) + return ret + + def assert_domain_enabled(self, domain_id, domain=None): + """Assert the Domain is enabled. + + :raise AssertionError if domain is disabled. + """ + if domain is None: + domain = self.get_domain(domain_id) + if not domain.get('enabled', True): + raise AssertionError(_('Domain is disabled: %s') % domain_id) + + def assert_domain_not_federated(self, domain_id, domain): + """Assert the Domain's name and id do not match the reserved keyword. + + Note that the reserved keyword is defined in the configuration file, + by default, it is 'Federated', it is also case insensitive. + If config's option is empty the default hardcoded value 'Federated' + will be used. + + :raise AssertionError if domain named match the value in the config. + + """ + # NOTE(marek-denis): We cannot create this attribute in the __init__ as + # config values are always initialized to default value. + federated_domain = (CONF.federation.federated_domain_name or + federation.FEDERATED_DOMAIN_KEYWORD).lower() + if (domain.get('name') and domain['name'].lower() == federated_domain): + raise AssertionError(_('Domain cannot be named %s') + % federated_domain) + if (domain_id.lower() == federated_domain): + raise AssertionError(_('Domain cannot have ID %s') + % federated_domain) + + def assert_project_enabled(self, project_id, project=None): + """Assert the project is enabled and its associated domain is enabled. + + :raise AssertionError if the project or domain is disabled. + """ + if project is None: + project = self.get_project(project_id) + self.assert_domain_enabled(domain_id=project['domain_id']) + if not project.get('enabled', True): + raise AssertionError(_('Project is disabled: %s') % project_id) + + @notifications.disabled(_PROJECT, public=False) + def _disable_project(self, project_id): + """Emit a notification to the callback system project is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + project scoped tokens) when a project is disabled. + + :param project_id: project identifier + :type project_id: string + """ + pass + + def _assert_all_parents_are_enabled(self, project_id): + parents_list = self.list_project_parents(project_id) + for project in parents_list: + if not project.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot enable project %s since it has ' + 'disabled parents') % project_id) + + def _assert_whole_subtree_is_disabled(self, project_id): + subtree_list = self.driver.list_projects_in_subtree(project_id) + for ref in subtree_list: + if ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot disable project %s since ' + 'its subtree contains enabled ' + 'projects') % project_id) + + def update_project(self, tenant_id, tenant, initiator=None): + original_tenant = self.driver.get_project(tenant_id) + tenant = tenant.copy() + + parent_id = original_tenant.get('parent_id') + if 'parent_id' in tenant and tenant.get('parent_id') != parent_id: + raise exception.ForbiddenAction( + action=_('Update of `parent_id` is not allowed.')) + + if 'enabled' in tenant: + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + + # NOTE(rodrigods): for the current implementation we only allow to + # disable a project if all projects below it in the hierarchy are + # already disabled. This also means that we can not enable a + # project that has disabled parents. + original_tenant_enabled = original_tenant.get('enabled', True) + tenant_enabled = tenant.get('enabled', True) + if not original_tenant_enabled and tenant_enabled: + self._assert_all_parents_are_enabled(tenant_id) + if original_tenant_enabled and not tenant_enabled: + self._assert_whole_subtree_is_disabled(tenant_id) + self._disable_project(tenant_id) + + ret = self.driver.update_project(tenant_id, tenant) + notifications.Audit.updated(self._PROJECT, tenant_id, initiator) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, original_tenant['name'], + original_tenant['domain_id']) + return ret + + def delete_project(self, tenant_id, initiator=None): + if not self.driver.is_leaf_project(tenant_id): + raise exception.ForbiddenAction( + action=_('cannot delete the project %s since it is not ' + 'a leaf in the hierarchy.') % tenant_id) + + project = self.driver.get_project(tenant_id) + project_user_ids = ( + self.assignment_api.list_user_ids_for_project(tenant_id)) + for user_id in project_user_ids: + payload = {'user_id': user_id, 'project_id': tenant_id} + self._emit_invalidate_user_project_tokens_notification(payload) + ret = self.driver.delete_project(tenant_id) + self.assignment_api.delete_project_assignments(tenant_id) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, project['name'], + project['domain_id']) + self.credential_api.delete_credentials_for_project(tenant_id) + notifications.Audit.deleted(self._PROJECT, tenant_id, initiator) + return ret + + def _filter_projects_list(self, projects_list, user_id): + user_projects = self.assignment_api.list_projects_for_user(user_id) + user_projects_ids = set([proj['id'] for proj in user_projects]) + # Keep only the projects present in user_projects + projects_list = [proj for proj in projects_list + if proj['id'] in user_projects_ids] + + def list_project_parents(self, project_id, user_id=None): + parents = self.driver.list_project_parents(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(parents, user_id) + return parents + + def _build_parents_as_ids_dict(self, project, parents_by_id): + # NOTE(rodrigods): we don't rely in the order of the projects returned + # by the list_project_parents() method. Thus, we create a project cache + # (parents_by_id) in order to access each parent in constant time and + # traverse up the hierarchy. + def traverse_parents_hierarchy(project): + parent_id = project.get('parent_id') + if not parent_id: + return None + + parent = parents_by_id[parent_id] + return {parent_id: traverse_parents_hierarchy(parent)} + + return traverse_parents_hierarchy(project) + + def get_project_parents_as_ids(self, project): + """Gets the IDs from the parents from a given project. + + The project IDs are returned as a structured dictionary traversing up + the hierarchy to the top level project. For example, considering the + following project hierarchy:: + + A + | + +-B-+ + | | + C D + + If we query for project C parents, the expected return is the following + dictionary:: + + 'parents': { + B['id']: { + A['id']: None + } + } + + """ + parents_list = self.list_project_parents(project['id']) + parents_as_ids = self._build_parents_as_ids_dict( + project, {proj['id']: proj for proj in parents_list}) + return parents_as_ids + + def list_projects_in_subtree(self, project_id, user_id=None): + subtree = self.driver.list_projects_in_subtree(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(subtree, user_id) + return subtree + + def _build_subtree_as_ids_dict(self, project_id, subtree_by_parent): + # NOTE(rodrigods): we perform a depth first search to construct the + # dictionaries representing each level of the subtree hierarchy. In + # order to improve this traversal performance, we create a cache of + # projects (subtree_py_parent) that accesses in constant time the + # direct children of a given project. + def traverse_subtree_hierarchy(project_id): + children = subtree_by_parent.get(project_id) + if not children: + return None + + children_ids = {} + for child in children: + children_ids[child['id']] = traverse_subtree_hierarchy( + child['id']) + return children_ids + + return traverse_subtree_hierarchy(project_id) + + def get_projects_in_subtree_as_ids(self, project_id): + """Gets the IDs from the projects in the subtree from a given project. + + The project IDs are returned as a structured dictionary representing + their hierarchy. For example, considering the following project + hierarchy:: + + A + | + +-B-+ + | | + C D + + If we query for project A subtree, the expected return is the following + dictionary:: + + 'subtree': { + B['id']: { + C['id']: None, + D['id']: None + } + } + + """ + def _projects_indexed_by_parent(projects_list): + projects_by_parent = {} + for proj in projects_list: + parent_id = proj.get('parent_id') + if parent_id: + if parent_id in projects_by_parent: + projects_by_parent[parent_id].append(proj) + else: + projects_by_parent[parent_id] = [proj] + return projects_by_parent + + subtree_list = self.list_projects_in_subtree(project_id) + subtree_as_ids = self._build_subtree_as_ids_dict( + project_id, _projects_indexed_by_parent(subtree_list)) + return subtree_as_ids + + @MEMOIZE + def get_domain(self, domain_id): + return self.driver.get_domain(domain_id) + + @MEMOIZE + def get_domain_by_name(self, domain_name): + return self.driver.get_domain_by_name(domain_name) + + def create_domain(self, domain_id, domain, initiator=None): + if (not self.identity_api.multiple_domains_supported and + domain_id != CONF.identity.default_domain_id): + raise exception.Forbidden(_('Multiple domains are not supported')) + self.assert_domain_not_federated(domain_id, domain) + domain.setdefault('enabled', True) + domain['enabled'] = clean.domain_enabled(domain['enabled']) + ret = self.driver.create_domain(domain_id, domain) + + notifications.Audit.created(self._DOMAIN, domain_id, initiator) + + if MEMOIZE.should_cache(ret): + self.get_domain.set(ret, self, domain_id) + self.get_domain_by_name.set(ret, self, ret['name']) + return ret + + @manager.response_truncated + def list_domains(self, hints=None): + return self.driver.list_domains(hints or driver_hints.Hints()) + + @notifications.disabled(_DOMAIN, public=False) + def _disable_domain(self, domain_id): + """Emit a notification to the callback system domain is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + domain scoped tokens) when a domain is disabled. + + :param domain_id: domain identifier + :type domain_id: string + """ + pass + + def update_domain(self, domain_id, domain, initiator=None): + self.assert_domain_not_federated(domain_id, domain) + original_domain = self.driver.get_domain(domain_id) + if 'enabled' in domain: + domain['enabled'] = clean.domain_enabled(domain['enabled']) + ret = self.driver.update_domain(domain_id, domain) + notifications.Audit.updated(self._DOMAIN, domain_id, initiator) + # disable owned users & projects when the API user specifically set + # enabled=False + if (original_domain.get('enabled', True) and + not domain.get('enabled', True)): + notifications.Audit.disabled(self._DOMAIN, domain_id, initiator, + public=False) + + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, original_domain['name']) + return ret + + def delete_domain(self, domain_id, initiator=None): + # explicitly forbid deleting the default domain (this should be a + # carefully orchestrated manual process involving configuration + # changes, etc) + if domain_id == CONF.identity.default_domain_id: + raise exception.ForbiddenAction(action=_('delete the default ' + 'domain')) + + domain = self.driver.get_domain(domain_id) + + # To help avoid inadvertent deletes, we insist that the domain + # has been previously disabled. This also prevents a user deleting + # their own domain since, once it is disabled, they won't be able + # to get a valid token to issue this delete. + if domain['enabled']: + raise exception.ForbiddenAction( + action=_('cannot delete a domain that is enabled, ' + 'please disable it first.')) + + self._delete_domain_contents(domain_id) + # Delete any database stored domain config + self.domain_config_api.delete_config_options(domain_id) + self.domain_config_api.delete_config_options(domain_id, sensitive=True) + # TODO(henry-nash): Although the controller will ensure deletion of + # all users & groups within the domain (which will cause all + # assignments for those users/groups to also be deleted), there + # could still be assignments on this domain for users/groups in + # other domains - so we should delete these here by making a call + # to the backend to delete all assignments for this domain. + # (see Bug #1277847) + self.driver.delete_domain(domain_id) + notifications.Audit.deleted(self._DOMAIN, domain_id, initiator) + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, domain['name']) + + def _delete_domain_contents(self, domain_id): + """Delete the contents of a domain. + + Before we delete a domain, we need to remove all the entities + that are owned by it, i.e. Projects. To do this we + call the delete function for these entities, which are + themselves responsible for deleting any credentials and role grants + associated with them as well as revoking any relevant tokens. + + """ + + def _delete_projects(project, projects, examined): + if project['id'] in examined: + msg = _LE('Circular reference or a repeated entry found ' + 'projects hierarchy - %(project_id)s.') + LOG.error(msg, {'project_id': project['id']}) + return + + examined.add(project['id']) + children = [proj for proj in projects + if proj.get('parent_id') == project['id']] + for proj in children: + _delete_projects(proj, projects, examined) + + try: + self.delete_project(project['id']) + except exception.ProjectNotFound: + LOG.debug(('Project %(projectid)s not found when ' + 'deleting domain contents for %(domainid)s, ' + 'continuing with cleanup.'), + {'projectid': project['id'], + 'domainid': domain_id}) + + proj_refs = self.list_projects_in_domain(domain_id) + + # Deleting projects recursively + roots = [x for x in proj_refs if x.get('parent_id') is None] + examined = set() + for project in roots: + _delete_projects(project, proj_refs, examined) + + @manager.response_truncated + def list_projects(self, hints=None): + return self.driver.list_projects(hints or driver_hints.Hints()) + + # NOTE(henry-nash): list_projects_in_domain is actually an internal method + # and not exposed via the API. Therefore there is no need to support + # driver hints for it. + def list_projects_in_domain(self, domain_id): + return self.driver.list_projects_in_domain(domain_id) + + @MEMOIZE + def get_project(self, project_id): + return self.driver.get_project(project_id) + + @MEMOIZE + def get_project_by_name(self, tenant_name, domain_id): + return self.driver.get_project_by_name(tenant_name, domain_id) + + @notifications.internal( + notifications.INVALIDATE_USER_PROJECT_TOKEN_PERSISTENCE) + def _emit_invalidate_user_project_tokens_notification(self, payload): + # This notification's payload is a dict of user_id and + # project_id so the token provider can invalidate the tokens + # from persistence if persistence is enabled. + pass + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + + def _get_list_limit(self): + return CONF.resource.list_limit or CONF.list_limit + + @abc.abstractmethod + def get_project_by_name(self, tenant_name, domain_id): + """Get a tenant by name. + + :returns: tenant_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # domain crud + @abc.abstractmethod + def create_domain(self, domain_id, domain): + """Creates a new domain. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains(self, hints): + """List domains in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of domain_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains_from_ids(self, domain_ids): + """List domains for the provided list of ids. + + :param domain_ids: list of ids + + :returns: a list of domain_refs. + + This method is used internally by the assignment manager to bulk read + a set of domains given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain(self, domain_id): + """Get a domain by ID. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain_by_name(self, domain_name): + """Get a domain by name. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_domain(self, domain_id, domain): + """Updates an existing domain. + + :raises: keystone.exception.DomainNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_domain(self, domain_id): + """Deletes an existing domain. + + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # project crud + @abc.abstractmethod + def create_project(self, project_id, project): + """Creates a new project. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects(self, hints): + """List projects in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_from_ids(self, project_ids): + """List projects for the provided list of ids. + + :param project_ids: list of ids + + :returns: a list of project_refs. + + This method is used internally by the assignment manager to bulk read + a set of projects given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_ids_from_domain_ids(self, domain_ids): + """List project ids for the provided list of domain ids. + + :param domain_ids: list of domain ids + + :returns: a list of project ids owned by the specified domain ids. + + This method is used internally by the assignment manager to bulk read + a set of project ids given a list of domain ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_in_domain(self, domain_id): + """List projects in the domain. + + :param domain_id: the driver MUST only return projects + within this domain. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_project(self, project_id): + """Get a project by ID. + + :returns: project_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_project(self, project_id, project): + """Updates an existing project. + + :raises: keystone.exception.ProjectNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_project(self, project_id): + """Deletes an existing project. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_parents(self, project_id): + """List all parents from a project by its ID. + + :param project_id: the driver will list the parents of this + project. + + :returns: a list of project_refs or an empty list. + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def list_projects_in_subtree(self, project_id): + """List all projects in the subtree below the hierarchy of the + given project. + + :param project_id: the driver will get the subtree under + this project. + + :returns: a list of project_refs or an empty list + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def is_leaf_project(self, project_id): + """Checks if a project is a leaf in the hierarchy. + + :param project_id: the driver will check if this project + is a leaf in the hierarchy. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + # Domain management functions for backends that only allow a single + # domain. Currently, this is only LDAP, but might be used by other + # backends in the future. + def _set_default_domain(self, ref): + """If the domain ID has not been set, set it to the default.""" + if isinstance(ref, dict): + if 'domain_id' not in ref: + ref = ref.copy() + ref['domain_id'] = CONF.identity.default_domain_id + return ref + elif isinstance(ref, list): + return [self._set_default_domain(x) for x in ref] + else: + raise ValueError(_('Expected dict or list: %s') % type(ref)) + + def _validate_default_domain(self, ref): + """Validate that either the default domain or nothing is specified. + + Also removes the domain from the ref so that LDAP doesn't have to + persist the attribute. + + """ + ref = ref.copy() + domain_id = ref.pop('domain_id', CONF.identity.default_domain_id) + self._validate_default_domain_id(domain_id) + return ref + + def _validate_default_domain_id(self, domain_id): + """Validate that the domain ID specified belongs to the default domain. + + """ + if domain_id != CONF.identity.default_domain_id: + raise exception.DomainNotFound(domain_id=domain_id) + + +@dependency.provider('domain_config_api') +class DomainConfigManager(manager.Manager): + """Default pivot point for the Domain Config backend.""" + + # NOTE(henry-nash): In order for a config option to be stored in the + # standard table, it must be explicitly whitelisted. Options marked as + # sensitive are stored in a separate table. Attempting to store options + # that are not listed as either whitelisted or sensitive will raise an + # exception. + # + # Only those options that affect the domain-specific driver support in + # the identity manager are supported. + + whitelisted_options = { + 'identity': ['driver'], + 'ldap': [ + 'url', 'user', 'suffix', 'use_dumb_member', 'dumb_member', + 'allow_subtree_delete', 'query_scope', 'page_size', + 'alias_dereferencing', 'debug_level', 'chase_referrals', + 'user_tree_dn', 'user_filter', 'user_objectclass', + 'user_id_attribute', 'user_name_attribute', 'user_mail_attribute', + 'user_pass_attribute', 'user_enabled_attribute', + 'user_enabled_invert', 'user_enabled_mask', 'user_enabled_default', + 'user_attribute_ignore', 'user_default_project_id_attribute', + 'user_allow_create', 'user_allow_update', 'user_allow_delete', + 'user_enabled_emulation', 'user_enabled_emulation_dn', + 'user_additional_attribute_mapping', 'group_tree_dn', + 'group_filter', 'group_objectclass', 'group_id_attribute', + 'group_name_attribute', 'group_member_attribute', + 'group_desc_attribute', 'group_attribute_ignore', + 'group_allow_create', 'group_allow_update', 'group_allow_delete', + 'group_additional_attribute_mapping', 'tls_cacertfile', + 'tls_cacertdir', 'use_tls', 'tls_req_cert', 'use_pool', + 'pool_size', 'pool_retry_max', 'pool_retry_delay', + 'pool_connection_timeout', 'pool_connection_lifetime', + 'use_auth_pool', 'auth_pool_size', 'auth_pool_connection_lifetime' + ] + } + sensitive_options = { + 'identity': [], + 'ldap': ['password'] + } + + def __init__(self): + super(DomainConfigManager, self).__init__(CONF.domain_config.driver) + + def _assert_valid_config(self, config): + """Ensure the options in the config are valid. + + This method is called to validate the request config in create and + update manager calls. + + :param config: config structure being created or updated + + """ + # Something must be defined in the request + if not config: + raise exception.InvalidDomainConfig( + reason=_('No options specified')) + + # Make sure the groups/options defined in config itself are valid + for group in config: + if (not config[group] or not + isinstance(config[group], dict)): + msg = _('The value of group %(group)s specified in the ' + 'config should be a dictionary of options') % { + 'group': group} + raise exception.InvalidDomainConfig(reason=msg) + for option in config[group]: + self._assert_valid_group_and_option(group, option) + + def _assert_valid_group_and_option(self, group, option): + """Ensure the combination of group and option is valid. + + :param group: optional group name, if specified it must be one + we support + :param option: optional option name, if specified it must be one + we support and a group must also be specified + + """ + if not group and not option: + # For all calls, it's OK for neither to be defined, it means you + # are operating on all config options for that domain. + return + + if not group and option: + # Our API structure should prevent this from ever happening, so if + # it does, then this is coding error. + msg = _('Option %(option)s found with no group specified while ' + 'checking domain configuration request') % { + 'option': option} + raise exception.UnexpectedError(exception=msg) + + if (group and group not in self.whitelisted_options and + group not in self.sensitive_options): + msg = _('Group %(group)s is not supported ' + 'for domain specific configurations') % {'group': group} + raise exception.InvalidDomainConfig(reason=msg) + + if option: + if (option not in self.whitelisted_options[group] and option not in + self.sensitive_options[group]): + msg = _('Option %(option)s in group %(group)s is not ' + 'supported for domain specific configurations') % { + 'group': group, 'option': option} + raise exception.InvalidDomainConfig(reason=msg) + + def _is_sensitive(self, group, option): + return option in self.sensitive_options[group] + + def _config_to_list(self, config): + """Build whitelisted and sensitive lists for use by backend drivers.""" + + whitelisted = [] + sensitive = [] + for group in config: + for option in config[group]: + the_list = (sensitive if self._is_sensitive(group, option) + else whitelisted) + the_list.append({ + 'group': group, 'option': option, + 'value': config[group][option]}) + + return whitelisted, sensitive + + def _list_to_config(self, whitelisted, sensitive=None, req_option=None): + """Build config dict from a list of option dicts. + + :param whitelisted: list of dicts containing options and their groups, + this has already been filtered to only contain + those options to include in the output. + :param sensitive: list of dicts containing sensitive options and their + groups, this has already been filtered to only + contain those options to include in the output. + :param req_option: the individual option requested + + :returns: a config dict, including sensitive if specified + + """ + the_list = whitelisted + (sensitive or []) + if not the_list: + return {} + + if req_option: + # The request was specific to an individual option, so + # no need to include the group in the output. We first check that + # there is only one option in the answer (and that it's the right + # one) - if not, something has gone wrong and we raise an error + if len(the_list) > 1 or the_list[0]['option'] != req_option: + LOG.error(_LE('Unexpected results in response for domain ' + 'config - %(count)s responses, first option is ' + '%(option)s, expected option %(expected)s'), + {'count': len(the_list), 'option': list[0]['option'], + 'expected': req_option}) + raise exception.UnexpectedError( + _('An unexpected error occurred when retrieving domain ' + 'configs')) + return {the_list[0]['option']: the_list[0]['value']} + + config = {} + for option in the_list: + config.setdefault(option['group'], {}) + config[option['group']][option['option']] = option['value'] + + return config + + def create_config(self, domain_id, config): + """Create config for a domain + + :param domain_id: the domain in question + :param config: the dict of config groups/options to assign to the + domain + + Creates a new config, overwriting any previous config (no Conflict + error will be generated). + + :returns: a dict of group dicts containing the options, with any that + are sensitive removed + :raises keystone.exception.InvalidDomainConfig: when the config + contains options we do not support + + """ + self._assert_valid_config(config) + whitelisted, sensitive = self._config_to_list(config) + # Delete any existing config + self.delete_config_options(domain_id) + self.delete_config_options(domain_id, sensitive=True) + # ...and create the new one + for option in whitelisted: + self.create_config_option( + domain_id, option['group'], option['option'], option['value']) + for option in sensitive: + self.create_config_option( + domain_id, option['group'], option['option'], option['value'], + sensitive=True) + return self._list_to_config(whitelisted) + + def get_config(self, domain_id, group=None, option=None): + """Get config, or partial config, for a domain + + :param domain_id: the domain in question + :param group: an optional specific group of options + :param option: an optional specific option within the group + + :returns: a dict of group dicts containing the whitelisted options, + filtered by group and option specified + :raises keystone.exception.DomainConfigNotFound: when no config found + that matches domain_id, group and option specified + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support + + An example response:: + + { + 'ldap': { + 'url': 'myurl' + 'user_tree_dn': 'OU=myou'}, + 'identity': { + 'driver': 'keystone.identity.backends.ldap.Identity'} + + } + + """ + self._assert_valid_group_and_option(group, option) + whitelisted = self.list_config_options(domain_id, group, option) + if whitelisted: + return self._list_to_config(whitelisted, req_option=option) + + if option: + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + elif group: + msg = _('group %(group)s') % {'group': group} + else: + msg = _('any options') + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + def update_config(self, domain_id, config, group=None, option=None): + """Update config, or partial config, for a domain + + :param domain_id: the domain in question + :param config: the config dict containing and groups/options being + updated + :param group: an optional specific group of options, which if specified + must appear in config, with no other groups + :param option: an optional specific option within the group, which if + specified must appear in config, with no other options + + The contents of the supplied config will be merged with the existing + config for this domain, updating or creating new options if these did + not previously exist. If group or option is specified, then the update + will be limited to those specified items and the inclusion of other + options in the supplied config will raise an exception, as will the + situation when those options do not already exist in the current + config. + + :returns: a dict of groups containing all whitelisted options + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support or one that does not exist in the original config + + """ + def _assert_valid_update(domain_id, config, group=None, option=None): + """Ensure the combination of config, group and option is valid.""" + + self._assert_valid_config(config) + self._assert_valid_group_and_option(group, option) + + # If a group has been specified, then the request is to + # explicitly only update the options in that group - so the config + # must not contain anything else. Further, that group must exist in + # the original config. Likewise, if an option has been specified, + # then the group in the config must only contain that option and it + # also must exist in the original config. + if group: + if len(config) != 1 or (option and len(config[group]) != 1): + if option: + msg = _('Trying to update option %(option)s in group ' + '%(group)s, so that, and only that, option ' + 'must be specified in the config') % { + 'group': group, 'option': option} + else: + msg = _('Trying to update group %(group)s, so that, ' + 'and only that, group must be specified in ' + 'the config') % {'group': group} + raise exception.InvalidDomainConfig(reason=msg) + + # So we now know we have the right number of entries in the + # config that align with a group/option being specified, but we + # must also make sure they match. + if group not in config: + msg = _('request to update group %(group)s, but config ' + 'provided contains group %(group_other)s ' + 'instead') % { + 'group': group, + 'group_other': config.keys()[0]} + raise exception.InvalidDomainConfig(reason=msg) + if option and option not in config[group]: + msg = _('Trying to update option %(option)s in group ' + '%(group)s, but config provided contains option ' + '%(option_other)s instead') % { + 'group': group, 'option': option, + 'option_other': config[group].keys()[0]} + raise exception.InvalidDomainConfig(reason=msg) + + # Finally, we need to check if the group/option specified + # already exists in the original config - since if not, to keep + # with the semantics of an update, we need to fail with + # a DomainConfigNotFound + if not self.get_config_with_sensitive_info(domain_id, + group, option): + if option: + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + else: + msg = _('group %(group)s') % {'group': group} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + def _update_or_create(domain_id, option, sensitive): + """Update the option, if it doesn't exist then create it.""" + + try: + self.create_config_option( + domain_id, option['group'], option['option'], + option['value'], sensitive=sensitive) + except exception.Conflict: + self.update_config_option( + domain_id, option['group'], option['option'], + option['value'], sensitive=sensitive) + + update_config = config + if group and option: + # The config will just be a dict containing the option and + # its value, so make it look like a single option under the + # group in question + update_config = {group: config} + + _assert_valid_update(domain_id, update_config, group, option) + + whitelisted, sensitive = self._config_to_list(update_config) + + for new_option in whitelisted: + _update_or_create(domain_id, new_option, sensitive=False) + for new_option in sensitive: + _update_or_create(domain_id, new_option, sensitive=True) + + return self.get_config(domain_id) + + def delete_config(self, domain_id, group=None, option=None): + """Delete config, or partial config, for the domain. + + :param domain_id: the domain in question + :param group: an optional specific group of options + :param option: an optional specific option within the group + + If group and option are None, then the entire config for the domain + is deleted. If group is not None, then just that group of options will + be deleted. If group and option are both specified, then just that + option is deleted. + + :raises keystone.exception.InvalidDomainConfig: when group/option + parameters specify an option we do not support or one that + does not exist in the original config. + + """ + self._assert_valid_group_and_option(group, option) + if group: + # As this is a partial delete, then make sure the items requested + # are valid and exist in the current config + current_config = self.get_config_with_sensitive_info(domain_id) + # Raise an exception if the group/options specified don't exist in + # the current config so that the delete method provides the + # correct error semantics. + current_group = current_config.get(group) + if not current_group: + msg = _('group %(group)s') % {'group': group} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + if option and not current_group.get(option): + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + self.delete_config_options(domain_id, group, option) + self.delete_config_options(domain_id, group, option, sensitive=True) + + def get_config_with_sensitive_info(self, domain_id, group=None, + option=None): + """Get config for a domain with sensitive info included. + + This method is not exposed via the public API, but is used by the + identity manager to initialize a domain with the fully formed config + options. + + """ + whitelisted = self.list_config_options(domain_id, group, option) + sensitive = self.list_config_options(domain_id, group, option, + sensitive=True) + + # Check if there are any sensitive substitutions needed. We first try + # and simply ensure any sensitive options that have valid substitution + # references in the whitelisted options are substituted. We then check + # the resulting whitelisted option and raise a warning if there + # appears to be an unmatched or incorrectly constructed substitution + # reference. To avoid the risk of logging any sensitive options that + # have already been substituted, we first take a copy of the + # whitelisted option. + + # Build a dict of the sensitive options ready to try substitution + sensitive_dict = {s['option']: s['value'] for s in sensitive} + + for each_whitelisted in whitelisted: + if not isinstance(each_whitelisted['value'], six.string_types): + # We only support substitutions into string types, if its an + # integer, list etc. then just continue onto the next one + continue + + # Store away the original value in case we need to raise a warning + # after substitution. + original_value = each_whitelisted['value'] + warning_msg = '' + try: + each_whitelisted['value'] = ( + each_whitelisted['value'] % sensitive_dict) + except KeyError: + warning_msg = _LW( + 'Found what looks like an unmatched config option ' + 'substitution reference - domain: %(domain)s, group: ' + '%(group)s, option: %(option)s, value: %(value)s. Perhaps ' + 'the config option to which it refers has yet to be ' + 'added?') + except (ValueError, TypeError): + warning_msg = _LW( + 'Found what looks like an incorrectly constructed ' + 'config option substitution reference - domain: ' + '%(domain)s, group: %(group)s, option: %(option)s, ' + 'value: %(value)s.') + + if warning_msg: + LOG.warn(warning_msg % { + 'domain': domain_id, + 'group': each_whitelisted['group'], + 'option': each_whitelisted['option'], + 'value': original_value}) + + return self._list_to_config(whitelisted, sensitive) + + +@six.add_metaclass(abc.ABCMeta) +class DomainConfigDriver(object): + """Interface description for a Domain Config driver.""" + + @abc.abstractmethod + def create_config_option(self, domain_id, group, option, value, + sensitive=False): + """Creates a config option for a domain. + + :param domain_id: the domain for this option + :param group: the group name + :param option: the option name + :param value: the value to assign to this option + :param sensitive: whether the option is sensitive + + :returns: dict containing group, option and value + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_config_option(self, domain_id, group, option, sensitive=False): + """Gets the config option for a domain. + + :param domain_id: the domain for this option + :param group: the group name + :param option: the option name + :param sensitive: whether the option is sensitive + + :returns: dict containing group, option and value + :raises: keystone.exception.DomainConfigNotFound: the option doesn't + exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_config_options(self, domain_id, group=None, option=False, + sensitive=False): + """Gets a config options for a domain. + + :param domain_id: the domain for this option + :param group: optional group option name + :param option: optional option name. If group is None, then this + parameter is ignored + :param sensitive: whether the option is sensitive + + :returns: list of dicts containing group, option and value + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_config_option(self, domain_id, group, option, value, + sensitive=False): + """Updates a config option for a domain. + + :param domain_id: the domain for this option + :param group: the group option name + :param option: the option name + :param value: the value to assign to this option + :param sensitive: whether the option is sensitive + + :returns: dict containing updated group, option and value + :raises: keystone.exception.DomainConfigNotFound: the option doesn't + exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_config_options(self, domain_id, group=None, option=None, + sensitive=False): + """Deletes config options for a domain. + + Allows deletion of all options for a domain, all options in a group + or a specific option. The driver is silent if there are no options + to delete. + + :param domain_id: the domain for this option + :param group: optional group option name + :param option: optional option name. If group is None, then this + parameter is ignored + :param sensitive: whether the option is sensitive + + """ + raise exception.NotImplemented() # pragma: no cover diff --git a/keystone-moon/keystone/resource/routers.py b/keystone-moon/keystone/resource/routers.py new file mode 100644 index 00000000..8ccd10aa --- /dev/null +++ b/keystone-moon/keystone/resource/routers.py @@ -0,0 +1,94 @@ +# Copyright 2013 Metacloud, Inc. +# Copyright 2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""WSGI Routers for the Resource service.""" + +from keystone.common import json_home +from keystone.common import router +from keystone.common import wsgi +from keystone.resource import controllers + + +class Admin(wsgi.ComposableRouter): + def add_routes(self, mapper): + # Tenant Operations + tenant_controller = controllers.Tenant() + mapper.connect('/tenants', + controller=tenant_controller, + action='get_all_projects', + conditions=dict(method=['GET'])) + mapper.connect('/tenants/{tenant_id}', + controller=tenant_controller, + action='get_project', + conditions=dict(method=['GET'])) + + +class Routers(wsgi.RoutersBase): + + def append_v3_routers(self, mapper, routers): + routers.append( + router.Router(controllers.DomainV3(), + 'domains', 'domain', + resource_descriptions=self.v3_resources)) + + config_controller = controllers.DomainConfigV3() + + self._add_resource( + mapper, config_controller, + path='/domains/{domain_id}/config', + get_head_action='get_domain_config', + put_action='create_domain_config', + patch_action='update_domain_config_only', + delete_action='delete_domain_config', + rel=json_home.build_v3_resource_relation('domain_config'), + status=json_home.Status.EXPERIMENTAL, + path_vars={ + 'domain_id': json_home.Parameters.DOMAIN_ID + }) + + config_group_param = ( + json_home.build_v3_parameter_relation('config_group')) + self._add_resource( + mapper, config_controller, + path='/domains/{domain_id}/config/{group}', + get_head_action='get_domain_config', + patch_action='update_domain_config_group', + delete_action='delete_domain_config', + rel=json_home.build_v3_resource_relation('domain_config_group'), + status=json_home.Status.EXPERIMENTAL, + path_vars={ + 'domain_id': json_home.Parameters.DOMAIN_ID, + 'group': config_group_param + }) + + self._add_resource( + mapper, config_controller, + path='/domains/{domain_id}/config/{group}/{option}', + get_head_action='get_domain_config', + patch_action='update_domain_config', + delete_action='delete_domain_config', + rel=json_home.build_v3_resource_relation('domain_config_option'), + status=json_home.Status.EXPERIMENTAL, + path_vars={ + 'domain_id': json_home.Parameters.DOMAIN_ID, + 'group': config_group_param, + 'option': json_home.build_v3_parameter_relation( + 'config_option') + }) + + routers.append( + router.Router(controllers.ProjectV3(), + 'projects', 'project', + resource_descriptions=self.v3_resources)) diff --git a/keystone-moon/keystone/resource/schema.py b/keystone-moon/keystone/resource/schema.py new file mode 100644 index 00000000..0fd59e3f --- /dev/null +++ b/keystone-moon/keystone/resource/schema.py @@ -0,0 +1,75 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystone.common import validation +from keystone.common.validation import parameter_types + + +_project_properties = { + 'description': validation.nullable(parameter_types.description), + # NOTE(lbragstad): domain_id isn't nullable according to some backends. + # The identity-api should be updated to be consistent with the + # implementation. + 'domain_id': parameter_types.id_string, + 'enabled': parameter_types.boolean, + 'parent_id': validation.nullable(parameter_types.id_string), + 'name': { + 'type': 'string', + 'minLength': 1, + 'maxLength': 64 + } +} + +project_create = { + 'type': 'object', + 'properties': _project_properties, + # NOTE(lbragstad): A project name is the only parameter required for + # project creation according to the Identity V3 API. We should think + # about using the maxProperties validator here, and in update. + 'required': ['name'], + 'additionalProperties': True +} + +project_update = { + 'type': 'object', + 'properties': _project_properties, + # NOTE(lbragstad) Make sure at least one property is being updated + 'minProperties': 1, + 'additionalProperties': True +} + +_domain_properties = { + 'description': validation.nullable(parameter_types.description), + 'enabled': parameter_types.boolean, + 'name': { + 'type': 'string', + 'minLength': 1, + 'maxLength': 64 + } +} + +domain_create = { + 'type': 'object', + 'properties': _domain_properties, + # TODO(lbragstad): According to the V3 API spec, name isn't required but + # the current implementation in assignment.controller:DomainV3 requires a + # name for the domain. + 'required': ['name'], + 'additionalProperties': True +} + +domain_update = { + 'type': 'object', + 'properties': _domain_properties, + 'minProperties': 1, + 'additionalProperties': True +} |