[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.
--- 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: