diff --git a/keystone/common/models.py b/keystone/common/models.py index 86a327c206..8ae2fede03 100644 --- a/keystone/common/models.py +++ b/keystone/common/models.py @@ -116,7 +116,7 @@ class Group(Model): """ required_keys = ('id', 'name', 'domain_id') - optional_keys = ('description') + optional_keys = ('description',) class Project(Model): @@ -162,3 +162,21 @@ class Trust(Model): required_keys = ('id', 'trustor_user_id', 'trustee_user_id', 'project_id') optional_keys = tuple('expires_at') + + +class Domain(Model): + """Domain object. + + Required keys: + id + name + + Optional keys: + + description + enabled (bool, default True) + + """ + + required_keys = ('id', 'name') + optional_keys = ('description', 'enabled') diff --git a/keystone/config.py b/keystone/config.py index 8f4ac16a0e..c1706a4642 100644 --- a/keystone/config.py +++ b/keystone/config.py @@ -310,12 +310,26 @@ register_str('group_id_attribute', group='ldap', default='cn') register_str('group_name_attribute', group='ldap', default='ou') register_str('group_member_attribute', group='ldap', default='member') register_str('group_desc_attribute', group='ldap', default='description') -register_str('group_domain_id_attribute', group='ldap', default='domain_id') +register_str('group_domain_id_attribute', group='ldap', + default='businessCategory') register_list('group_attribute_ignore', group='ldap', default='') register_bool('group_allow_create', group='ldap', default=True) register_bool('group_allow_update', group='ldap', default=True) register_bool('group_allow_delete', group='ldap', default=True) +register_str('domain_tree_dn', group='ldap', default=None) +register_str('domain_filter', group='ldap', default=None) +register_str('domain_objectclass', group='ldap', default='groupOfNames') +register_str('domain_id_attribute', group='ldap', default='cn') +register_str('domain_name_attribute', group='ldap', default='ou') +register_str('domain_member_attribute', group='ldap', default='member') +register_str('domain_desc_attribute', group='ldap', default='description') +register_str('domain_enabled_attribute', group='ldap', default='enabled') +register_list('domain_attribute_ignore', group='ldap', default='') +register_bool('domain_allow_create', group='ldap', default=True) +register_bool('domain_allow_update', group='ldap', default=True) +register_bool('domain_allow_delete', group='ldap', default=True) + # pam register_str('url', group='pam', default=None) register_str('userid', group='pam', default=None) diff --git a/keystone/identity/backends/ldap/core.py b/keystone/identity/backends/ldap/core.py index 72446ce3b0..1160ac6d28 100644 --- a/keystone/identity/backends/ldap/core.py +++ b/keystone/identity/backends/ldap/core.py @@ -28,7 +28,6 @@ from keystone import config from keystone import exception from keystone import identity - CONF = config.CONF @@ -44,6 +43,7 @@ class Identity(identity.Driver): self.project = ProjectApi(CONF) self.role = RoleApi(CONF) self.group = GroupApi(CONF) + self.domain = DomainApi(CONF) def get_connection(self, user=None, password=None): if self.LDAP_URL.startswith('fake://'): @@ -238,6 +238,62 @@ class Identity(identity.Driver): def delete_group(self, group_id): return self.group.delete(group_id) + def add_user_to_group(self, user_id, group_id): + self.get_user(user_id) + self.get_group(group_id) + self.group.add_user(user_id, group_id) + + def remove_user_from_group(self, user_id, group_id): + self.get_user(user_id) + self.get_group(group_id) + self.group.remove_user(user_id, group_id) + + def list_groups_for_user(self, user_id): + self.get_user(user_id) + return self.group.list_user_groups(user_id) + + def list_groups(self): + return self.group.get_all() + + def list_users_in_group(self, group_id): + self.get_group(group_id) + return self.group.list_group_users(group_id) + + def check_user_in_group(self, user_id, group_id): + self.get_user(user_id) + self.get_group(group_id) + user_refs = self.list_users_in_group(group_id) + found = False + for x in user_refs: + if x['id'] == user_id: + found = True + break + return found + + def create_domain(self, domain_id, domain): + domain['name'] = clean.domain_name(domain['name']) + return self.domain.create(domain) + + def get_domain(self, domain_id): + try: + return self.domain.get(domain_id) + except exception.NotFound: + raise exception.DomainNotFound(domain_id=domain_id) + + def update_domain(self, domain_id, domain): + if 'name' in domain: + domain['name'] = clean.domain_name(domain['name']) + return self.domain.update(domain_id, domain) + + def delete_domain(self, domain_id): + try: + return self.domain.delete(domain_id) + except ldap.NO_SUCH_OBJECT: + raise exception.DomainNotFound(domain_id=domain_id) + + def list_domains(self): + return self.domain.get_all() + # TODO(termie): remove this and move cross-api calls into driver class ApiShim(object): @@ -251,6 +307,7 @@ class ApiShim(object): _project = None _user = None _group = None + _domain = None def __init__(self, conf): self.conf = conf @@ -275,9 +332,15 @@ class ApiShim(object): @property def group(self): - if not self.group: - self.group = GroupApi(self.conf) - return self.group + if not self._group: + self._group = GroupApi(self.conf) + return self._group + + @property + def domain(self): + if not self._domain: + self._domain = DomainApi(self.conf) + return self._domain # TODO(termie): remove this and move cross-api calls into driver @@ -300,6 +363,10 @@ class ApiShimMixin(object): def group_api(self): return self.api.group + @property + def domain_api(self): + return self.api.domain + # TODO(termie): turn this into a data object and move logic to driver class UserApi(common_ldap.EnabledEmuMixIn, common_ldap.BaseLdap, ApiShimMixin): @@ -569,7 +636,6 @@ class RoleApi(common_ldap.BaseLdap, ApiShimMixin): def create(self, values): #values['id'] = values['name'] #delattr(values, 'name') - return super(RoleApi, self).create(values) def add_user(self, role_id, user_id, tenant_id=None): @@ -736,9 +802,29 @@ class RoleApi(common_ldap.BaseLdap, ApiShimMixin): pass super(RoleApi, self).delete(id) +# TODO (spzala) - this is only placeholder for group and domain role support +# which will be added under bug 1101287 + def roles_delete_subtree_by_type(self, id, type): + conn = self.get_connection() + query = '(objectClass=%s)' % self.object_class + dn = None + if type == 'Group': + dn = self.group_api._id_to_dn(id) + if type == 'Domain': + dn = self.domain_api._id_to_dn(id) + if dn: + try: + roles = conn.search_s(dn, ldap.SCOPE_ONELEVEL, + query, ['%s' % '1.1']) + for role_dn, _ in roles: + try: + conn.delete_s(role_dn) + except: + raise Exception + except ldap.NO_SUCH_OBJECT: + pass + -# TODO (henry-nash) This is a placeholder for the full LDPA implementation -# This needs to be completed (see Bug #1092187) class GroupApi(common_ldap.BaseLdap, ApiShimMixin): DEFAULT_OU = 'ou=UserGroups' DEFAULT_STRUCTURAL_CLASSES = [] @@ -771,13 +857,15 @@ class GroupApi(common_ldap.BaseLdap, ApiShimMixin): data = values.copy() if data.get('id') is None: data['id'] = uuid.uuid4().hex + if 'description' in data and data['description'] in ['', None]: + data.pop('description') return super(GroupApi, self).create(data) def delete(self, id): if self.subtree_delete_enabled: super(GroupApi, self).deleteTree(id) else: - self.role_api.roles_delete_subtree_by_group(id) + self.role_api.roles_delete_subtree_by_type(id, 'Group') super(GroupApi, self).delete(id) def update(self, id, values): @@ -786,3 +874,112 @@ class GroupApi(common_ldap.BaseLdap, ApiShimMixin): msg = _('Changing Name not supported by LDAP') raise exception.NotImplemented(message=msg) super(GroupApi, self).update(id, values, old_obj) + + def add_user(self, user_id, group_id): + conn = self.get_connection() + try: + conn.modify_s( + self._id_to_dn(group_id), + [(ldap.MOD_ADD, + self.member_attribute, + self.user_api._id_to_dn(user_id))]) + except ldap.TYPE_OR_VALUE_EXISTS: + msg = _('User %s is already a member of group %s' + % (user_id, group_id)) + raise exception.Conflict(msg) + + def remove_user(self, user_id, group_id): + conn = self.get_connection() + try: + conn.modify_s( + self._id_to_dn(group_id), + [(ldap.MOD_DELETE, + self.member_attribute, + self.user_api._id_to_dn(user_id))]) + except ldap.NO_SUCH_ATTRIBUTE: + raise exception.UserNotFound(user_id=user_id) + + def list_user_groups(self, user_id): + """Returns a list of groups a user has access to""" + user_dn = self.user_api._id_to_dn(user_id) + query = '(%s=%s)' % (self.member_attribute, user_dn) + memberships = self.get_all(query) + return memberships + + def list_group_users(self, group_id): + """Returns a list of users that belong to a group""" + query = '(objectClass=%s)' % self.object_class + conn = self.get_connection() + group_dn = self._id_to_dn(group_id) + try: + attrs = conn.search_s(group_dn, + ldap.SCOPE_BASE, + query, ['%s' % self.member_attribute]) + except ldap.NO_SUCH_OBJECT: + return [] + users = [] + for dn, member in attrs: + user_dns = member[self.member_attribute] + for user_dn in user_dns: + if self.use_dumb_member and user_dn == self.dumb_member: + continue + user_id = self.user_api._dn_to_id(user_dn) + users.append(self.user_api.get(user_id)) + return users + + +class DomainApi(common_ldap.BaseLdap, ApiShimMixin): + DEFAULT_OU = 'ou=Domains' + DEFAULT_STRUCTURAL_CLASSES = [] + DEFAULT_OBJECTCLASS = 'groupOfNames' + DEFAULT_ID_ATTR = 'cn' + DEFAULT_MEMBER_ATTRIBUTE = 'member' + DEFAULT_ATTRIBUTE_IGNORE = [] + options_name = 'domain' + attribute_mapping = {'name': 'ou', + 'description': 'description', + 'domainId': 'cn', + 'enabled': 'enabled'} + model = models.Domain + + def __init__(self, conf): + super(DomainApi, self).__init__(conf) + self.api = ApiShim(conf) + self.attribute_mapping['name'] = conf.ldap.domain_name_attribute + self.attribute_mapping['description'] = conf.ldap.domain_desc_attribute + self.attribute_mapping['enabled'] = conf.ldap.tenant_enabled_attribute + self.member_attribute = (getattr(conf.ldap, 'domain_member_attribute') + or self.DEFAULT_MEMBER_ATTRIBUTE) + self.attribute_ignore = (getattr(conf.ldap, 'domain_attribute_ignore') + or self.DEFAULT_ATTRIBUTE_IGNORE) + + def get(self, id, filter=None): + """Replaces exception.NotFound with exception.DomainNotFound.""" + try: + return super(DomainApi, self).get(id, filter) + except exception.NotFound: + raise exception.DomainNotFound(domain_id=id) + + def create(self, values): + self.affirm_unique(values) + data = values.copy() + if data.get('id') is None: + data['id'] = uuid.uuid4().hex + return super(DomainApi, self).create(data) + + def delete(self, id): + if self.subtree_delete_enabled: + super(DomainApi, self).deleteTree(id) + else: + self.role_api.roles_delete_subtree_by_type(id, 'Domain') + super(DomainApi, self).delete(id) + + def update(self, id, values): + try: + old_obj = self.get(id) + except exception.NotFound: + raise exception.DomainNotFound(domain_id=id) + if old_obj['name'] != values['name']: + msg = _('Changing Name not supported by LDAP') + raise exception.NotImplemented(message=msg) + super(DomainApi, self).update(id, values, old_obj) diff --git a/keystone/identity/core.py b/keystone/identity/core.py index 30516e3f45..ec5410e978 100644 --- a/keystone/identity/core.py +++ b/keystone/identity/core.py @@ -41,6 +41,7 @@ def filter_user(user_ref): user_ref.pop('password', None) user_ref.pop('tenants', None) user_ref.pop('groups', None) + user_ref.pop('domains', None) try: user_ref['extra'].pop('password', None) user_ref['extra'].pop('tenants', None) diff --git a/tests/_ldap_livetest.py b/tests/_ldap_livetest.py index 7eb343e6b4..5f5f60cd91 100644 --- a/tests/_ldap_livetest.py +++ b/tests/_ldap_livetest.py @@ -67,11 +67,12 @@ class LiveLDAPIdentity(test_backend_ldap.LDAPIdentity): create_object(CONF.ldap.tenant_tree_dn, {'objectclass': 'organizationalUnit', 'ou': 'Projects'}) - - # NOTE(crazed): This feature is currently being added - create_object("ou=Groups,%s" % CONF.ldap.suffix, + create_object(CONF.ldap.domain_tree_dn, {'objectclass': 'organizationalUnit', - 'ou': 'Groups'}) + 'ou': 'Domain'}) + create_object(CONF.ldap.group_tree_dn, + {'objectclass': 'organizationalUnit', + 'ou': 'UserGroups'}) def _set_config(self): self.config([test.etcdir('keystone.conf.sample'), diff --git a/tests/backend_ldap.conf b/tests/backend_ldap.conf index 5afe80cbdc..6b3f8a7537 100644 --- a/tests/backend_ldap.conf +++ b/tests/backend_ldap.conf @@ -2,7 +2,7 @@ url = fake://memory user = cn=Admin password = password -backend_entities = ['Tenant', 'User', 'UserRoleAssociation', 'Role'] +backend_entities = ['Tenant', 'User', 'UserRoleAssociation', 'Role', 'Group', 'Domain'] suffix = cn=example,cn=com [identity] diff --git a/tests/backend_liveldap.conf b/tests/backend_liveldap.conf index 60a71cc8c2..cdaaa608ce 100644 --- a/tests/backend_liveldap.conf +++ b/tests/backend_liveldap.conf @@ -3,8 +3,10 @@ url = ldap://localhost user = dc=Manager,dc=openstack,dc=org password = test suffix = dc=openstack,dc=org +group_tree_dn = ou=UserGroups,dc=openstack,dc=org role_tree_dn = ou=Roles,dc=openstack,dc=org tenant_tree_dn = ou=Projects,dc=openstack,dc=org +domain_tree_dn = ou=Domains,dc=openstack,dc=org user_tree_dn = ou=Users,dc=openstack,dc=org tenant_enabled_emulation = True user_enabled_emulation = True diff --git a/tests/test_backend_ldap.py b/tests/test_backend_ldap.py index 8ea514bc1b..b0749d9e93 100644 --- a/tests/test_backend_ldap.py +++ b/tests/test_backend_ldap.py @@ -359,29 +359,43 @@ class LDAPIdentity(test.TestCase, test_backend.IdentityTests): # TODO (henry-nash) These need to be removed when the full LDAP implementation # is submitted - see Bugs 1092187, 1101287, 1101276, 1101289 + + # (spzala)The group and domain crud tests below override the standard ones + # in test_backend.py so that we can exclude the update name test, since we + # do not yet support the update of either group or domain names with LDAP. + # In the tests below, the update is demonstrated by updating description. + # Refer to bug 1136403 for more detail. def test_group_crud(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') + group = {'id': uuid.uuid4().hex, 'domain_id': uuid.uuid4().hex, + 'name': uuid.uuid4().hex, 'description': uuid.uuid4().hex} + self.identity_api.create_group(group['id'], group) + group_ref = self.identity_api.get_group(group['id']) + self.assertDictEqual(group_ref, group) + group['description'] = uuid.uuid4().hex + self.identity_api.update_group(group['id'], group) + group_ref = self.identity_api.get_group(group['id']) + self.assertDictEqual(group_ref, group) - def test_add_user_to_group(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') + self.identity_api.delete_group(group['id']) + self.assertRaises(exception.GroupNotFound, + self.identity_api.get_group, + group['id']) - def test_add_user_to_group_404(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') + def test_domain_crud(self): + domain = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex, + 'enabled': True, 'description': uuid.uuid4().hex} + self.identity_api.create_domain(domain['id'], domain) + domain_ref = self.identity_api.get_domain(domain['id']) + self.assertDictEqual(domain_ref, domain) + domain['description'] = uuid.uuid4().hex + self.identity_api.update_domain(domain['id'], domain) + domain_ref = self.identity_api.get_domain(domain['id']) + self.assertDictEqual(domain_ref, domain) - def test_check_user_in_group(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - - def test_check_user_not_in_group(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - - def test_list_users_in_group(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - - def test_remove_user_from_group(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - - def test_remove_user_from_group_404(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') + self.identity_api.delete_domain(domain['id']) + self.assertRaises(exception.DomainNotFound, + self.identity_api.get_domain, + domain['id']) def test_get_role_grant_by_user_and_project(self): raise nose.exc.SkipTest('Blocked by bug 1101287') @@ -407,15 +421,6 @@ class LDAPIdentity(test.TestCase, test_backend.IdentityTests): def test_get_and_remove_correct_role_grant_from_a_mix(self): raise nose.exc.SkipTest('Blocked by bug 1101287') - def test_get_roles_for_user_and_domain(self): - raise nose.exc.SkipTest('Blocked by bug 1101276') - - def test_get_roles_for_user_and_domain_404(self): - raise nose.exc.SkipTest('Blocked by bug 1101276') - - def test_domain_crud(self): - raise nose.exc.SkipTest('Blocked by bug 1101276') - def test_project_crud(self): # NOTE(topol): LDAP implementation does not currently support the # updating of a project name so this method override @@ -467,12 +472,6 @@ class LDAPIdentity(test.TestCase, test_backend.IdentityTests): def test_delete_group_with_user_project_domain_links(self): raise nose.exc.SkipTest('Blocked by bug 1101287') - def test_list_groups(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - - def test_list_domains(self): - raise nose.exc.SkipTest('Blocked by bug 1101276') - def test_list_user_projects(self): raise nose.exc.SkipTest('Blocked by bug 1101287') @@ -485,9 +484,6 @@ class LDAPIdentity(test.TestCase, test_backend.IdentityTests): def test_create_duplicate_project_name_in_different_domains(self): raise nose.exc.SkipTest('Blocked by bug 1101276') - def test_create_duplicate_group_name_fails(self): - raise nose.exc.SkipTest('Blocked by bug 1092187') - def test_create_duplicate_group_name_in_different_domains(self): raise nose.exc.SkipTest('Blocked by bug 1101276') @@ -509,6 +505,9 @@ class LDAPIdentity(test.TestCase, test_backend.IdentityTests): def test_move_project_between_domains_with_clashing_names_fails(self): raise nose.exc.SkipTest('Blocked by bug 1101276') + def test_get_roles_for_user_and_domain(self): + raise nose.exc.SkipTest('Blocked by bug 1101287') + class LDAPIdentityEnabledEmulation(LDAPIdentity): def setUp(self):