Fix CEO group add for rfc2307bis
[public/pyceo-broken.git] / pylib / csc / backends / ldapi.py
1 """
2 LDAP Backend Interface
3
4 This module is intended to be a thin wrapper around LDAP operations.
5 Methods on the connection object correspond in a straightforward way
6 to LDAP queries and updates.
7
8 A LDAP entry is the most important component of a CSC UNIX account.
9 The entry contains the username, user id number, real name, shell,
10 and other important information. All non-local UNIX accounts must
11 have an LDAP entry, even if the account does not log in directly.
12
13 This module makes use of python-ldap, a Python module with bindings
14 to libldap, OpenLDAP's native C client library.
15 """
16 import ldap.modlist
17
18
19 class LDAPException(Exception):
20     """Exception class for LDAP-related errors."""
21
22
23 class LDAPConnection(object):
24     """
25     Connection to the LDAP directory. All directory
26     queries and updates are made via this class.
27
28     Exceptions: (all methods)
29         LDAPException - on directory query failure
30
31     Example:
32          connection = LDAPConnection()
33          connection.connect(...)
34
35          # make queries and updates, e.g.
36          connection.user_delete('mspang')
37
38          connection.disconnect()
39     """
40
41     def __init__(self):
42         self.ldap = None
43
44     
45     def connect(self, server, bind_dn, bind_pw, user_base, group_base):
46         """
47         Establish a connection to the LDAP Server.
48
49         Parameters:
50             server     - connection string (e.g. ldap://foo.com, ldaps://bar.com)
51             bind_dn    - distinguished name to bind to
52             bind_pw    - password of bind_dn
53             user_base  - base of the users subtree
54             group_base - baes of the group subtree
55
56         Example: connect('ldaps:///', 'cn=ceo,dc=csclub,dc=uwaterloo,dc=ca',
57                      'secret', 'ou=People,dc=csclub,dc=uwaterloo,dc=ca',
58                      'ou=Group,dc=csclub,dc=uwaterloo,dc=ca')
59         
60         """
61
62         if bind_pw is None: bind_pw = ''
63
64         try:
65
66             # open the connection
67             self.ldap = ldap.initialize(server)
68
69             # authenticate
70             self.ldap.simple_bind_s(bind_dn, bind_pw)
71
72         except ldap.LDAPError, e:
73             raise LDAPException("unable to connect: %s" % e)
74
75         self.user_base = user_base
76         self.group_base = group_base
77
78
79     def disconnect(self):
80         """Close the connection to the LDAP server."""
81         
82         if self.ldap:
83
84             # close connection
85             try:
86                 self.ldap.unbind_s()
87                 self.ldap = None
88             except ldap.LDAPError, e:
89                 raise LDAPException("unable to disconnect: %s" % e)
90
91
92     def connected(self):
93         """Determine whether the connection has been established."""
94
95         return self.ldap is not None
96
97
98
99     ### Helper Methods ###
100
101     def lookup(self, dn, objectClass=None):
102         """
103         Helper method to retrieve the attributes of an entry.
104
105         Parameters:
106             dn - the distinguished name of the directory entry
107
108         Returns: a dictionary of attributes of the matched dn, or
109                  None of the dn does not exist in the directory
110         """
111
112         if not self.connected(): raise LDAPException("Not connected!")
113
114         # search for the specified dn
115         try:
116             if objectClass:
117                 search_filter = '(objectClass=%s)' % self.escape(objectClass)
118                 matches = self.ldap.search_s(dn, ldap.SCOPE_BASE, search_filter)
119             else:
120                 matches = self.ldap.search_s(dn, ldap.SCOPE_BASE)
121         except ldap.NO_SUCH_OBJECT:
122             return None
123         except ldap.LDAPError, e:
124             raise LDAPException("unable to lookup dn %s: %s" % (dn, e))
125             
126         # this should never happen due to the nature of DNs
127         if len(matches) > 1:
128             raise LDAPException("duplicate dn in ldap: " + dn)
129
130         # dn was found, but didn't match the objectClass filter
131         elif len(matches) < 1:
132             return None
133
134         # return the attributes of the single successful match
135         match = matches[0]
136         match_dn, match_attributes = match
137         return match_attributes
138
139
140
141     ### User-related Methods ###
142
143     def user_lookup(self, uid, objectClass=None):
144         """
145         Retrieve the attributes of a user.
146
147         Parameters:
148             uid - the uid to look up
149
150         Returns: attributes of user with uid
151         """
152
153         dn = 'uid=' + uid + ',' + self.user_base
154         return self.lookup(dn, objectClass)
155
156
157     def user_search(self, search_filter, params):
158         """
159         Search for users with a filter.
160
161         Parameters:
162             search_filter - LDAP filter string to match users against
163
164         Returns: a dictionary mapping uids to attributes
165         """
166
167         if not self.connected(): raise LDAPException("Not connected!")
168
169         search_filter = search_filter % tuple(self.escape(x) for x in params)
170
171         # search for entries that match the filter
172         try:
173             matches = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, search_filter)
174         except ldap.LDAPError, e:
175             raise LDAPException("user search failed: %s" % e)
176
177         results = {}
178         for match in matches:
179             dn, attrs = match
180             uid = attrs['uid'][0]
181             results[uid] = attrs
182
183         return results
184
185
186     def user_modify(self, uid, attrs):
187         """
188         Update user attributes in the directory.
189
190         Parameters:
191             uid   - username of the user to modify
192             attrs - dictionary as returned by user_lookup() with changes to make.
193                     omitted attributes are DELETED.
194
195         Example: user = user_lookup('mspang')
196                  user['uidNumber'] = [ '0' ]
197                  connection.user_modify('mspang', user)
198         """
199
200         # distinguished name of the entry to modify
201         dn = 'uid=' + uid + ',' + self.user_base
202
203         # retrieve current state of user
204         old_user = self.user_lookup(uid)
205
206         try:
207
208             # build list of modifications to make
209             changes = ldap.modlist.modifyModlist(old_user, attrs)
210
211             # apply changes
212             self.ldap.modify_s(dn, changes)
213
214         except ldap.LDAPError, e:
215             raise LDAPException("unable to modify: %s" % e)
216
217
218     def user_delete(self, uid):
219         """
220         Removes a user from the directory.
221
222         Example: connection.user_delete('mspang')
223         """
224
225         try:
226             dn = 'uid=' + uid + ',' + self.user_base
227             self.ldap.delete_s(dn)
228         except ldap.LDAPError, e:
229             raise LDAPException("unable to delete: %s" % e)
230
231
232
233     ### Account-related Methods ###
234
235     def account_lookup(self, uid):
236         """
237         Retrieve the attributes of an account.
238
239         Parameters:
240             uid - the uid to look up
241
242         Returns: attributes of user with uid
243         """
244
245         return self.user_lookup(uid, 'posixAccount')
246
247
248     def account_search_id(self, uidNumber):
249         """
250         Retrieves a list of accounts with a certain UNIX uid number.
251
252         LDAP (or passwd for that matter) does not enforce any restriction on
253         the number of accounts that can have a certain UID number. Therefore
254         this method returns a list of matches.
255
256         Parameters:
257             uidNumber - the user id of the accounts desired
258
259         Returns: a dictionary mapping uids to attributes
260
261         Example: connection.account_search_id(21292) -> {'mspang': { ... }}
262         """
263
264         search_filter = '(&(objectClass=posixAccount)(uidNumber=%s))'
265         return self.user_search(search_filter, [ uidNumber ])
266
267
268     def account_search_gid(self, gidNumber):
269         """
270         Retrieves a list of accounts with a certain UNIX gid
271         number (search by default group).
272
273         Returns: a dictionary mapping uids to attributes
274         """
275
276         search_filter = '(&(objectClass=posixAccount)(gidNumber=%s))'
277         return self.user_search(search_filter, [ gidNumber ])
278
279
280     def account_add(self, uid, cn, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None, description=None, update=False):
281         """
282         Adds a user account to the directory.
283
284         Parameters:
285             uid           - the UNIX username for the account
286             cn            - the real name of the member
287             uidNumber     - the UNIX user id number
288             gidNumber     - the UNIX group id number (default group)
289             homeDirectory - home directory for the user
290             loginShell    - login shell for the user
291             gecos         - comment field (usually stores name etc)
292             description   - description field (optional and unimportant)
293             update        - if True, will update existing entries
294
295         Example: connection.user_add('mspang', 'Michael Spang',
296                      21292, 100, '/users/mspang', '/bin/bash',
297                      'Michael Spang,,,')
298         """
299
300         if not self.connected(): raise LDAPException("Not connected!")
301
302         dn = 'uid=' + uid + ',' + self.user_base
303         attrs = {
304             'objectClass': [ 'top', 'account', 'posixAccount', 'shadowAccount' ],
305             'uid': [ uid ],
306             'cn': [ cn ],
307             'loginShell': [ loginShell ],
308             'uidNumber': [ str(uidNumber) ],
309             'gidNumber': [ str(gidNumber) ],
310             'homeDirectory': [ homeDirectory ],
311             'gecos': [ gecos ],
312         }
313
314         if loginShell:
315             attrs['loginShell'] = [ loginShell ]
316         if description:
317             attrs['description'] = [ description ]
318
319         try:
320
321             old_entry = self.user_lookup(uid)
322             if old_entry and 'posixAccount' not in old_entry['objectClass'] and update:
323
324                 attrs.update(old_entry)
325                 attrs['objectClass'] = list(attrs['objectClass'])
326                 attrs['objectClass'].append('posixAccount')
327                 if not 'shadowAccount' in attrs['objectClass']:
328                     attrs['objectClass'].append('shadowAccount')
329
330                 modlist = ldap.modlist.modifyModlist(old_entry, attrs)
331                 self.ldap.modify_s(dn, modlist)
332
333             else:
334
335                 modlist = ldap.modlist.addModlist(attrs)
336                 self.ldap.add_s(dn, modlist)
337
338         except ldap.LDAPError, e:
339             raise LDAPException("unable to add: %s" % e)
340
341
342
343     ### Group-related Methods ###
344
345     def group_lookup(self, cn):
346         """
347         Retrieves the attributes of a group.
348
349         Parameters:
350             cn - the UNIX group name to lookup
351
352         Returns: attributes of the group's LDAP entry
353
354         Example: connection.group_lookup('office') -> {
355                      'cn': 'office',
356                      'gidNumber', '1001',
357                      ...
358                  }
359         """
360
361         dn = 'cn=' + cn + ',' + self.group_base
362         return self.lookup(dn, 'posixGroup')
363
364
365     def group_search_id(self, gidNumber):
366         """
367         Retrieves a list of groups with the specified UNIX group number.
368         
369         Returns: a list of groups with gid gidNumber
370
371         Example: connection.group_search_id(1001) -> ['office']
372         """
373
374         if not self.connected(): raise LDAPException("Not connected!")
375
376         # search for posixAccount entries with the specified uidNumber
377         try:
378             search_filter = '(&(objectClass=posixGroup)(gidNumber=%d))' % gidNumber
379             matches = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, search_filter)
380         except ldap.LDAPError, e:
381             raise LDAPException("group search failed: %s" % e)
382
383         # list for groups found
384         group_cns = []
385
386         results = {}
387         for match in matches:
388             dn, attrs = match
389             uid = attrs['cn'][0]
390             results[uid] = attrs
391
392         return results
393
394
395     def group_add(self, cn, gidNumber, description=None):
396         """
397         Adds a group to the directory.
398
399         Example: connection.group_add('office', 1001, 'Office Staff')
400         """
401
402         if not self.connected(): raise LDAPException("Not connected!")
403
404         dn = 'cn=' + cn + ',' + self.group_base
405         attrs = {
406             'objectClass': [ 'top', 'posixGroup', 'group' ],
407             'cn': [ cn ],
408             'gidNumber': [ str(gidNumber) ],
409         }
410         if description:
411             attrs['description'] = description
412
413         try:
414             modlist = ldap.modlist.addModlist(attrs)
415             self.ldap.add_s(dn, modlist)
416         except ldap.LDAPError, e:
417             raise LDAPException("unable to add group: %s" % e)
418
419
420     def group_modify(self, cn, attrs):
421         """
422         Update group attributes in the directory.
423         
424         The only available updates are fairly destructive (rename or renumber)
425         but this method is provided for completeness.
426
427         Parameters:
428             cn    - name of the group to modify
429             entry - dictionary as returned by group_lookup() with changes to make.
430                     omitted attributes are DELETED.
431
432         Example: group = group_lookup('office')
433                  group['gidNumber'] = [ str(connection.first_id(20000, 40000)) ]
434                  del group['memberUid']
435                  connection.group_modify('office', group)
436         """
437
438         if not self.connected(): raise LDAPException("Not connected!")
439
440         # distinguished name of the entry to modify
441         dn = 'cn=' + cn + ',' + self.group_base
442
443         # retrieve current state of group
444         old_group = self.group_lookup(cn)
445
446         try:
447             
448             # build list of modifications to make
449             changes = ldap.modlist.modifyModlist(old_group, attrs)
450
451             # apply changes
452             self.ldap.modify_s(dn, changes)
453
454         except ldap.LDAPError, e:
455             raise LDAPException("unable to modify: %s" % e)
456
457
458     def group_delete(self, cn):
459         """
460         Removes a group from the directory."
461
462         Example: connection.group_delete('office')
463         """
464
465         if not self.connected(): raise LDAPException("Not connected!")
466
467         try:
468             dn = 'cn=' + cn + ',' + self.group_base
469             self.ldap.delete_s(dn)
470         except ldap.LDAPError, e:
471             raise LDAPException("unable to delete group: %s" % e)
472
473
474
475     ### Member-related Methods ###
476
477     def member_lookup(self, uid):
478         """
479         Retrieve the attributes of a member. This method will only return
480         results that have the objectClass 'member'.
481
482         Parameters:
483             uid - the username to look up
484
485         Returns: attributes of member with uid
486
487         Example: connection.member_lookup('mspang') ->
488                      { 'uid': 'mspang', 'uidNumber': 21292 ...}
489         """
490
491         if not self.connected(): raise LDAPException("Not connected!")
492
493         dn = 'uid=' + uid + ',' + self.user_base
494         return self.lookup(dn, 'member')
495
496
497     def member_search_name(self, name):
498         """
499         Retrieves a list of members with the specified name (fuzzy).
500
501         Returns: a dictionary mapping uids to attributes
502         """
503
504         search_filter = '(&(objectClass=member)(cn~=%s))'
505         return self.user_search(search_filter, [ name ] )
506
507
508     def member_search_term(self, term):
509         """
510         Retrieves a list of members who were registered in a certain term.
511
512         Returns: a dictionary mapping uids to attributes
513         """
514
515         search_filter = '(&(objectClass=member)(term=%s))'
516         return self.user_search(search_filter, [ term ])
517
518
519     def member_search_program(self, program):
520         """
521         Retrieves a list of members in a certain program (fuzzy).
522
523         Returns: a dictionary mapping uids to attributes
524         """
525
526         search_filter = '(&(objectClass=member)(program~=%s))'
527         return self.user_search(search_filter, [ program ])
528
529
530     def member_add(self, uid, cn, program=None, description=None):
531         """
532         Adds a member to the directory.
533
534         Parameters:
535             uid           - the UNIX username for the member
536             cn            - the real name of the member
537             program       - the member's program of study
538             description   - a description for the entry
539         """
540
541         dn = 'uid=' + uid + ',' + self.user_base
542         attrs = {
543             'objectClass': [ 'top', 'account', 'member' ],
544             'uid': [ uid ],
545             'cn': [ cn ],
546         }
547
548         if program:
549             attrs['program'] = [ program ]
550         if description:
551             attrs['description'] = [ description ]
552
553         try:
554             modlist = ldap.modlist.addModlist(attrs)
555             self.ldap.add_s(dn, modlist)
556         except ldap.LDAPError, e:
557             raise LDAPException("unable to add: %s" % e)
558
559
560     def member_add_account(self, uid, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None):
561         """
562         Adds login privileges to a member.
563         """
564
565         return self.account_add(uid, None, uidNumber, gidNumber, homeDirectory, loginShell, gecos, None, True)
566
567
568
569     ### Miscellaneous Methods ###
570
571     def escape(self, value):
572         """
573         Escapes special characters in a value so that it may be safely inserted
574         into an LDAP search filter.
575         """
576
577         value = str(value)
578         value = value.replace('\\', '\\5c').replace('*', '\\2a')
579         value = value.replace('(', '\\28').replace(')', '\\29')
580         value = value.replace('\x00', '\\00')
581         return value
582
583
584     def used_uids(self, minimum=None, maximum=None):
585         """
586         Compiles a list of used UIDs in a range.
587
588         Parameters:
589             minimum - smallest uid to return in the list
590             maximum - largest uid to return in the list
591
592         Returns: list of integer uids
593
594         Example: connection.used_uids(20000, 40000) -> [20000, 20001, ...]
595         """
596
597         if not self.connected(): raise LDAPException("Not connected!")
598
599         try:
600             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['uidNumber'])
601         except ldap.LDAPError, e:
602             raise LDAPException("search for uids failed: %s" % e)
603         
604         uids = []
605         for user in users:
606             dn, attrs = user
607             uid = int(attrs['uidNumber'][0])
608             if (not minimum or uid >= minimum) and (not maximum or uid <= maximum):
609                 uids.append(uid)
610
611         return uids
612             
613     
614     def used_gids(self, minimum=None, maximum=None):
615         """
616         Compiles a list of used GIDs in a range.
617
618         Parameters:
619             minimum - smallest gid to return in the list
620             maximum - largest gid to return in the list
621
622         Returns: list of integer gids
623
624         Example: connection.used_gids(20000, 40000) -> [20000, 20001, ...]
625         """
626
627         if not self.connected(): raise LDAPException("Not connected!")
628
629         try:
630             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['gidNumber'])
631         except ldap.LDAPError, e:
632             raise LDAPException("search for gids failed: %s" % e)
633         
634         gids = []
635         for user in users:
636             dn, attrs = user
637             gid = int(attrs['gidNumber'][0])
638             if (not minimum or gid >= minimum) and (not maximum or gid <= maximum):
639                 gids.append(gid)
640
641         return gids
642
643
644
645 ### Tests ###
646
647 if __name__ == '__main__':
648     
649     from csc.common.test import *
650
651     conffile = '/etc/csc/ldap.cf'
652     cfg = dict([map(str.strip, a.split("=", 1)) for a in map(str.strip, open(conffile).read().split("\n")) if "=" in a ]) 
653     srvurl = cfg['server_url'][1:-1]
654     binddn = cfg['admin_bind_dn'][1:-1]
655     bindpw = cfg['admin_bind_pw'][1:-1]
656     ubase = cfg['users_base'][1:-1]
657     gbase = cfg['groups_base'][1:-1]
658     minid = 99999000
659     maxid = 100000000
660
661     # t=test u=user g=group c=changed r=real e=expected
662     tuname = 'testuser'
663     turname = 'Test User'
664     tuhome = '/home/testuser'
665     tushell = '/bin/false'
666     tugecos = 'Test User,,,'
667     tgname = 'testgroup'
668     tmname = 'testmember'
669     tmrname = 'Test Member'
670     tmprogram = 'UBW'
671     tmdesc = 'Test Description'
672     cushell = '/bin/true'
673     cuhome = '/home/changed'
674     curname = 'Test Modified User'
675     cmhome = '/home/testmember'
676     cmshell = '/bin/false'
677     cmgecos = 'Test Member,,,'
678
679     test(LDAPConnection)
680     connection = LDAPConnection()
681     success()
682
683     test(LDAPConnection.disconnect)
684     connection.disconnect()
685     success()
686
687     test(LDAPConnection.connect)
688     connection.connect(srvurl, binddn, bindpw, ubase, gbase)
689     if not connection.connected():
690         fail("not connected")
691     success()
692
693     try:
694         connection.user_delete(tuname)
695         connection.user_delete(tmname)
696         connection.group_delete(tgname)
697     except LDAPException:
698         pass
699
700     test(LDAPConnection.used_uids)
701     uids = connection.used_uids(minid, maxid)
702     if type(uids) is not list:
703         fail("list not returned")
704     success()
705
706     test(LDAPConnection.used_gids)
707     gids = connection.used_gids(minid, maxid)
708     if type(gids) is not list:
709         fail("list not returned")
710     success()
711
712     unusedids = []
713     for idnum in xrange(minid, maxid):
714         if not idnum in uids and not idnum in gids:
715             unusedids.append(idnum)
716
717     tuuid = unusedids.pop()
718     tugid = unusedids.pop()
719     eudata = {
720             'uid': [ tuname ],
721             'loginShell': [ tushell ],
722             'uidNumber': [ str(tuuid) ],
723             'gidNumber': [ str(tugid) ],
724             'gecos': [ tugecos ],
725             'homeDirectory': [ tuhome ],
726             'cn': [ turname ]
727             }
728
729     test(LDAPConnection.account_add)
730     connection.account_add(tuname, turname, tuuid, tugid, tuhome, tushell, tugecos)
731     success()
732
733     emdata = {
734             'uid': [ tmname ],
735             'cn': [ tmrname ],
736             'program': [ tmprogram ],
737             'description': [ tmdesc ],
738     }
739
740     test(LDAPConnection.member_add)
741     connection.member_add(tmname, tmrname, tmprogram, tmdesc)
742     success()
743
744     tggid = unusedids.pop()
745     egdata = {
746             'cn': [ tgname ],
747             'gidNumber': [ str(tggid) ]
748             }
749
750     test(LDAPConnection.group_add)
751     connection.group_add(tgname, tggid)
752     success()
753
754     test(LDAPConnection.account_lookup)
755     udata = connection.account_lookup(tuname)
756     if udata: del udata['objectClass']
757     assert_equal(eudata, udata)
758     success()
759
760     test(LDAPConnection.member_lookup)
761     mdata = connection.member_lookup(tmname)
762     if mdata: del mdata['objectClass']
763     assert_equal(emdata, mdata)
764     success()
765
766     test(LDAPConnection.user_lookup)
767     udata = connection.user_lookup(tuname)
768     mdata = connection.user_lookup(tmname)
769     if udata: del udata['objectClass']
770     if mdata: del mdata['objectClass']
771     assert_equal(eudata, udata)
772     assert_equal(emdata, mdata)
773     success()
774
775     test(LDAPConnection.group_lookup)
776     gdata = connection.group_lookup(tgname)
777     if gdata: del gdata['objectClass']
778     assert_equal(egdata, gdata)
779     success()
780
781     test(LDAPConnection.account_search_id)
782     eulist = [ tuname ]
783     ulist = connection.account_search_id(tuuid).keys()
784     assert_equal(eulist, ulist)
785     success()
786
787     test(LDAPConnection.account_search_gid)
788     ulist = connection.account_search_gid(tugid)
789     if tuname not in ulist:
790         fail("%s not in %s" % (tuname, ulist))
791     success()
792
793     test(LDAPConnection.member_search_name)
794     mlist = connection.member_search_name(tmrname)
795     if tmname not in mlist:
796         fail("%s not in %s" % (tmname, mlist))
797     success()
798
799     test(LDAPConnection.member_search_program)
800     mlist = connection.member_search_program(tmprogram)
801     if tmname not in mlist:
802         fail("%s not in %s" % (tmname, mlist))
803     success()
804
805     test(LDAPConnection.group_search_id)
806     glist = connection.group_search_id(tggid).keys()
807     eglist = [ tgname ]
808     assert_equal(eglist, glist)
809     success()
810
811     ecudata = connection.account_lookup(tuname)
812     ecudata['loginShell'] = [ cushell ]
813     ecudata['homeDirectory'] = [ cuhome ]
814     ecudata['cn'] = [ curname ]
815
816     test(LDAPConnection.user_modify)
817     connection.user_modify(tuname, ecudata)
818     cudata = connection.account_lookup(tuname)
819     assert_equal(ecudata, cudata)
820     success()
821
822     tmuid = unusedids.pop()
823     tmgid = unusedids.pop()
824     emadata = emdata.copy()
825     emadata.update({
826             'loginShell': [ cmshell ],
827             'uidNumber': [ str(tmuid) ],
828             'gidNumber': [ str(tmgid) ],
829             'gecos': [ cmgecos ],
830             'homeDirectory': [ cmhome ],
831             })
832
833     test(LDAPConnection.member_add_account)
834     connection.member_add_account(tmname, tmuid, tmuid, cmhome, cmshell, cmgecos)
835     success()
836
837     ecgdata = connection.group_lookup(tgname)
838     ecgdata['memberUid'] = [ tuname ]
839
840     test(LDAPConnection.group_modify)
841     connection.group_modify(tgname, ecgdata)
842     cgdata = connection.group_lookup(tgname)
843     assert_equal(ecgdata, cgdata)
844     success()
845
846     test(LDAPConnection.group_delete)
847     connection.group_delete(tgname)
848     success()
849
850     test(LDAPConnection.disconnect)
851     connection.disconnect()
852     success()