[ldapfeed] Add support for LDAP groups (closes #2528116)
authorDavid Douard <david.douard@logilab.fr>
Wed, 24 Apr 2013 18:11:37 +0200
changeset 8922 715b9eec6da9
parent 8921 da46624a0880
child 8923 acff6dfcb9f4
child 8924 8666438778c7
[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.
server/sources/ldapfeed.py
server/test/data/ldap_test.ldif
server/test/unittest_ldapsource.py
sobjects/ldapparser.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 <http://www.gnu.org/licenses/>.
 """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
--- 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
--- 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'
--- 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: