# HG changeset patch # User David Douard # Date 1366819897 -7200 # Node ID 715b9eec6da9b3e8f38155ce512277206e75a3d4 # Parent da46624a088018eae82a6df6609c76ebee56c291 [ldapfeed] Add support for LDAP groups (closes #2528116) Groups from the LDAP server are imported as CWGourp entities, and in_group relationships are created between existing CWUsers and CWGroups Unit tests have been refactored a bit, especially ti add helper methods to manage LDAP database. diff -r da46624a0880 -r 715b9eec6da9 server/sources/ldapfeed.py --- a/server/sources/ldapfeed.py Wed Apr 24 17:57:14 2013 +0200 +++ b/server/sources/ldapfeed.py Wed Apr 24 18:11:37 2013 +0200 @@ -17,10 +17,20 @@ # with CubicWeb. If not, see . """cubicweb ldap feed source""" +import ldap +from ldap.filter import filter_format + from cubicweb.cwconfig import merge_options from cubicweb.server.sources import datafeed -from cubicweb.server import ldaputils +from cubicweb.server import ldaputils, utils +from cubicweb import Binary + +_ = unicode +# search scopes +ldapscope = {'BASE': ldap.SCOPE_BASE, + 'ONELEVEL': ldap.SCOPE_ONELEVEL, + 'SUBTREE': ldap.SCOPE_SUBTREE} class LDAPFeedSource(ldaputils.LDAPSourceMixIn, datafeed.DataFeedSource): @@ -31,7 +41,65 @@ support_entities = {'CWUser': False} use_cwuri_as_url = False + options_group = ( + ('group-base-dn', + {'type' : 'string', + 'default': '', + 'help': 'base DN to lookup for groups; disable group importation mechanism if unset', + 'group': 'ldap-source', 'level': 1, + }), + ('group-scope', + {'type' : 'choice', + 'default': 'ONELEVEL', + 'choices': ('BASE', 'ONELEVEL', 'SUBTREE'), + 'help': 'group search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")', + 'group': 'ldap-source', 'level': 1, + }), + ('group-classes', + {'type' : 'csv', + 'default': ('top', 'posixGroup'), + 'help': 'classes of group', + 'group': 'ldap-source', 'level': 1, + }), + ('group-filter', + {'type': 'string', + 'default': '', + 'help': 'additional filters to be set in the ldap query to find valid groups', + 'group': 'ldap-source', 'level': 2, + }), + ('group-attrs-map', + {'type' : 'named', + 'default': {'cn': 'name', 'memberUid': 'member'}, + 'help': 'map from ldap group attributes to cubicweb attributes', + 'group': 'ldap-source', 'level': 1, + }), + ) + options = merge_options(datafeed.DataFeedSource.options - + ldaputils.LDAPSourceMixIn.options, - optgroup='ldap-source') + + ldaputils.LDAPSourceMixIn.options + + options_group, + optgroup='ldap-source',) + def update_config(self, source_entity, typedconfig): + """update configuration from source entity. `typedconfig` is config + properly typed with defaults set + """ + super(LDAPFeedSource, self).update_config(source_entity, typedconfig) + self.group_base_dn = str(typedconfig['group-base-dn']) + self.group_base_scope = ldapscope[typedconfig['group-scope']] + self.group_attrs = typedconfig['group-attrs-map'] + self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'} + self.group_attrs.update(typedconfig['group-attrs-map']) + self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.iteritems()) + self.group_base_filters = [filter_format('(%s=%s)', ('objectClass', o)) + for o in typedconfig['group-classes']] + if typedconfig['group-filter']: + self.group_base_filters.append(typedconfig['group-filter']) + + def _process_ldap_item(self, dn, iterator): + itemdict = super(LDAPFeedSource, self)._process_ldap_item(dn, iterator) + # we expect memberUid to be a list of user ids, make sure of it + member = self.group_rev_attrs['member'] + if isinstance(itemdict.get(member), basestring): + itemdict[member] = [itemdict[member]] + return itemdict diff -r da46624a0880 -r 715b9eec6da9 server/test/data/ldap_test.ldif --- a/server/test/data/ldap_test.ldif Wed Apr 24 17:57:14 2013 +0200 +++ b/server/test/data/ldap_test.ldif Wed Apr 24 18:11:37 2013 +0200 @@ -10,6 +10,25 @@ ou: People structuralObjectClass: organizationalUnit +dn: ou=Group,dc=cubicweb,dc=test +objectClass: organizationalUnit +ou: Group + +dn: cn=logilab,ou=Group,dc=cubicweb,dc=test +gidNumber: 2000 +objectClass: posixGroup +objectClass: top +cn: logilab +memberUid: adim + +dn: cn=dir,ou=Group,dc=cubicweb,dc=test +gidNumber: 2002 +objectClass: posixGroup +objectClass: top +cn: dir +memberUid: adim +memberUid: syt + dn: uid=syt,ou=People,dc=cubicweb,dc=test loginShell: /bin/bash objectClass: OpenLDAPperson diff -r da46624a0880 -r 715b9eec6da9 server/test/unittest_ldapsource.py --- a/server/test/unittest_ldapsource.py Wed Apr 24 17:57:14 2013 +0200 +++ b/server/test/unittest_ldapsource.py Wed Apr 24 18:11:37 2013 +0200 @@ -35,7 +35,13 @@ from cubicweb.server.sources.ldapuser import GlobTrFunc, UnknownEid, RQL2LDAPFilter -CONFIG_LDAPFEED = CONFIG_LDAPUSER = u''' +CONFIG_LDAPFEED = u''' +user-base-dn=ou=People,dc=cubicweb,dc=test +group-base-dn=ou=Group,dc=cubicweb,dc=test +user-attrs-map=uid=login,mail=email,userPassword=upassword +group-attrs-map=cn=name,memberUid=member +''' +CONFIG_LDAPUSER = u''' user-base-dn=ou=People,dc=cubicweb,dc=test user-attrs-map=uid=login,mail=email,userPassword=upassword ''' @@ -351,6 +357,77 @@ self.assertRaises(AuthenticationError, self.repo.connect, 'syt', password='toto') self.assertTrue(self.repo.connect('syt', password='syt')) +class LDAPFeedGroupTC(LDAPFeedTestBase): + """ + A testcase for group support in ldapfeed. + """ + + def test_groups_exist(self): + rset = self.sexecute('CWGroup X WHERE X name "dir"') + self.assertEqual(len(rset), 1) + + rset = self.sexecute('CWGroup X WHERE X cw_source S, S name "ldap"') + self.assertEqual(len(rset), 2) + + def test_group_deleted(self): + rset = self.sexecute('CWGroup X WHERE X name "dir"') + self.assertEqual(len(rset), 1) + + def test_in_group(self): + rset = self.sexecute('CWGroup X WHERE X name %(name)s', {'name': 'dir'}) + dirgroup = rset.get_entity(0, 0) + self.assertEqual(set(['syt', 'adim']), + set([u.login for u in dirgroup.reverse_in_group])) + rset = self.sexecute('CWGroup X WHERE X name %(name)s', {'name': 'logilab'}) + logilabgroup = rset.get_entity(0, 0) + self.assertEqual(set(['adim']), + set([u.login for u in logilabgroup.reverse_in_group])) + + def test_group_member_added(self): + self.pull() + rset = self.sexecute('Any L WHERE U in_group G, G name %(name)s, U login L', + {'name': 'logilab'}) + self.assertEqual(len(rset), 1) + self.assertEqual(rset[0][0], 'adim') + + try: + self.update_ldap_entry('cn=logilab,ou=Group,dc=cubicweb,dc=test', + {('add', 'memberUid'): ['syt']}) + time.sleep(1.1) # timestamps precision is 1s + self.pull() + + rset = self.sexecute('Any L WHERE U in_group G, G name %(name)s, U login L', + {'name': 'logilab'}) + self.assertEqual(len(rset), 2) + self.assertEqual(rset[0][0], 'adim') + self.assertEqual(rset[1][0], 'syt') + + finally: + # back to normal ldap setup + self.tearDownClass() + self.setUpClass() + + def test_group_member_deleted(self): + self.pull() # ensure we are sync'ed + rset = self.sexecute('Any L WHERE U in_group G, G name %(name)s, U login L', + {'name': 'logilab'}) + self.assertEqual(len(rset), 1) + self.assertEqual(rset[0][0], 'adim') + + try: + self.update_ldap_entry('cn=logilab,ou=Group,dc=cubicweb,dc=test', + {('delete', 'memberUid'): ['adim']}) + time.sleep(1.1) # timestamps precision is 1s + self.pull() + + rset = self.sexecute('Any L WHERE U in_group G, G name %(name)s, U login L', + {'name': 'logilab'}) + self.assertEqual(len(rset), 0) + finally: + # back to normal ldap setup + self.tearDownClass() + self.setUpClass() + class LDAPUserSourceTC(LDAPFeedTestBase): test_db_id = 'ldap-user' diff -r da46624a0880 -r 715b9eec6da9 sobjects/ldapparser.py --- a/sobjects/ldapparser.py Wed Apr 24 17:57:14 2013 +0200 +++ b/sobjects/ldapparser.py Wed Apr 24 18:11:37 2013 +0200 @@ -40,6 +40,11 @@ return '(&%s)' % ''.join(self.source.base_filters) @cachedproperty + def searchgroupfilterstr(self): + """ ldap search string, including user-filter """ + return '(&%s)' % ''.join(self.source.group_base_filters) + + @cachedproperty def user_source_entities_by_extid(self): source = self.source if source.user_base_dn.strip(): @@ -52,6 +57,19 @@ attrs)) return {} + @cachedproperty + def group_source_entities_by_extid(self): + source = self.source + if source.group_base_dn.strip(): + attrs = map(str, ['modifyTimestamp'] + source.group_attrs.keys()) + return dict((groupdict['dn'], groupdict) + for groupdict in source._search(self._cw, + source.group_base_dn, + source.group_base_scope, + self.searchgroupfilterstr, + attrs)) + return {} + def _process(self, etype, sdict): self.warning('fetched %s %s', etype, sdict) extid = sdict['dn'] @@ -70,6 +88,9 @@ self.debug('processing ldapfeed source %s %s', self.source, self.searchfilterstr) for userdict in self.user_source_entities_by_extid.itervalues(): self._process('CWUser', userdict) + self.debug('processing ldapfeed source %s %s', self.source, self.searchgroupfilterstr) + for groupdict in self.group_source_entities_by_extid.itervalues(): + self._process('CWGroup', groupdict) def handle_deletion(self, config, session, myuris): if config['delete-entities']: @@ -114,6 +135,8 @@ tdict = {} if etype == 'CWUser': items = self.source.user_attrs.iteritems() + elif etype == 'CWGroup': + items = self.source.group_attrs.iteritems() for sattr, tattr in items: if tattr not in self.non_attribute_keys: try: @@ -153,6 +176,8 @@ if groups: entity.cw_set(in_group=groups) self._process_email(entity, sourceparams) + elif etype == 'CWGroup': + self._process_membership(entity, sourceparams) def is_deleted(self, extidplus, etype, eid): try: @@ -187,6 +212,19 @@ self.sourceuris.pop(uri, None) # XXX else check use_email relation? + def _process_membership(self, entity, sourceparams): + """ Find existing CWUsers with the same login as the memberUids in the + CWGroup entity and create the in_group relationship """ + mdate = sourceparams.get('modification_date') + if (not mdate or mdate > entity.modification_date): + self._cw.execute('DELETE U in_group G WHERE G eid %(g)s', + {'g':entity.eid}) + members = sourceparams.get(self.source.group_rev_attrs['member']) + if members: + members = ["'%s'" % e for e in members] + rql = 'SET U in_group G WHERE G eid %%(g)s, U login IN (%s)' % ','.join(members) + self._cw.execute(rql, {'g':entity.eid, }) + @cached def _get_group(self, name): try: