Accept 'j' and 'k' for 'down' and 'up' in CEO menus.
[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' ],
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_studentid(self, studentid):
498         """
499         Retrieves a list of members with a certain studentid.
500
501         Returns: a dictionary mapping uids to attributes
502         """
503
504         search_filter = '(&(objectClass=member)(studentid=%s))'
505         return self.user_search(search_filter, [ studentid ] )
506
507
508     def member_search_name(self, name):
509         """
510         Retrieves a list of members with the specified name (fuzzy).
511
512         Returns: a dictionary mapping uids to attributes
513         """
514
515         search_filter = '(&(objectClass=member)(cn~=%s))'
516         return self.user_search(search_filter, [ name ] )
517
518
519     def member_search_term(self, term):
520         """
521         Retrieves a list of members who were registered in a certain term.
522
523         Returns: a dictionary mapping uids to attributes
524         """
525
526         search_filter = '(&(objectClass=member)(term=%s))'
527         return self.user_search(search_filter, [ term ])
528
529
530     def member_search_program(self, program):
531         """
532         Retrieves a list of members in a certain program (fuzzy).
533
534         Returns: a dictionary mapping uids to attributes
535         """
536
537         search_filter = '(&(objectClass=member)(program~=%s))'
538         return self.user_search(search_filter, [ program ])
539
540
541     def member_add(self, uid, cn, studentid, program=None, description=None):
542         """
543         Adds a member to the directory.
544
545         Parameters:
546             uid           - the UNIX username for the member
547             cn            - the real name of the member
548             studentid     - the member's student ID number
549             program       - the member's program of study
550             description   - a description for the entry
551         """
552
553         dn = 'uid=' + uid + ',' + self.user_base
554         attrs = {
555             'objectClass': [ 'top', 'account', 'member' ],
556             'uid': [ uid ],
557             'cn': [ cn ],
558             'studentid': [ studentid ],
559         }
560
561         if program:
562             attrs['program'] = [ program ]
563         if description:
564             attrs['description'] = [ description ]
565
566         try:
567             modlist = ldap.modlist.addModlist(attrs)
568             self.ldap.add_s(dn, modlist)
569         except ldap.LDAPError, e:
570             raise LDAPException("unable to add: %s" % e)
571
572
573     def member_add_account(self, uid, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None):
574         """
575         Adds login privileges to a member.
576         """
577
578         return self.account_add(uid, None, uidNumber, gidNumber, homeDirectory, loginShell, gecos, None, True)
579
580
581
582     ### Miscellaneous Methods ###
583
584     def escape(self, value):
585         """
586         Escapes special characters in a value so that it may be safely inserted
587         into an LDAP search filter.
588         """
589
590         value = str(value)
591         value = value.replace('\\', '\\5c').replace('*', '\\2a')
592         value = value.replace('(', '\\28').replace(')', '\\29')
593         value = value.replace('\x00', '\\00')
594         return value
595
596
597     def used_uids(self, minimum=None, maximum=None):
598         """
599         Compiles a list of used UIDs in a range.
600
601         Parameters:
602             minimum - smallest uid to return in the list
603             maximum - largest uid to return in the list
604
605         Returns: list of integer uids
606
607         Example: connection.used_uids(20000, 40000) -> [20000, 20001, ...]
608         """
609
610         if not self.connected(): raise LDAPException("Not connected!")
611
612         try:
613             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['uidNumber'])
614         except ldap.LDAPError, e:
615             raise LDAPException("search for uids failed: %s" % e)
616         
617         uids = []
618         for user in users:
619             dn, attrs = user
620             uid = int(attrs['uidNumber'][0])
621             if (not minimum or uid >= minimum) and (not maximum or uid <= maximum):
622                 uids.append(uid)
623
624         return uids
625             
626     
627     def used_gids(self, minimum=None, maximum=None):
628         """
629         Compiles a list of used GIDs in a range.
630
631         Parameters:
632             minimum - smallest gid to return in the list
633             maximum - largest gid to return in the list
634
635         Returns: list of integer gids
636
637         Example: connection.used_gids(20000, 40000) -> [20000, 20001, ...]
638         """
639
640         if not self.connected(): raise LDAPException("Not connected!")
641
642         try:
643             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['gidNumber'])
644         except ldap.LDAPError, e:
645             raise LDAPException("search for gids failed: %s" % e)
646         
647         gids = []
648         for user in users:
649             dn, attrs = user
650             gid = int(attrs['gidNumber'][0])
651             if (not minimum or gid >= minimum) and (not maximum or gid <= maximum):
652                 gids.append(gid)
653
654         return gids
655
656
657
658 ### Tests ###
659
660 if __name__ == '__main__':
661     
662     from csc.common.test import *
663
664     conffile = '/etc/csc/ldap.cf'
665     cfg = dict([map(str.strip, a.split("=", 1)) for a in map(str.strip, open(conffile).read().split("\n")) if "=" in a ]) 
666     srvurl = cfg['server_url'][1:-1]
667     binddn = cfg['admin_bind_dn'][1:-1]
668     bindpw = cfg['admin_bind_pw'][1:-1]
669     ubase = cfg['users_base'][1:-1]
670     gbase = cfg['groups_base'][1:-1]
671     minid = 99999000
672     maxid = 100000000
673
674     # t=test u=user g=group c=changed r=real e=expected
675     tuname = 'testuser'
676     turname = 'Test User'
677     tuhome = '/home/testuser'
678     tushell = '/bin/false'
679     tugecos = 'Test User,,,'
680     tgname = 'testgroup'
681     tmname = 'testmember'
682     tmrname = 'Test Member'
683     tmstudentid = '99999999'
684     tmprogram = 'UBW'
685     tmdesc = 'Test Description'
686     cushell = '/bin/true'
687     cuhome = '/home/changed'
688     curname = 'Test Modified User'
689     cmhome = '/home/testmember'
690     cmshell = '/bin/false'
691     cmgecos = 'Test Member,,,'
692
693     test(LDAPConnection)
694     connection = LDAPConnection()
695     success()
696
697     test(LDAPConnection.disconnect)
698     connection.disconnect()
699     success()
700
701     test(LDAPConnection.connect)
702     connection.connect(srvurl, binddn, bindpw, ubase, gbase)
703     if not connection.connected():
704         fail("not connected")
705     success()
706
707     try:
708         connection.user_delete(tuname)
709         connection.user_delete(tmname)
710         connection.group_delete(tgname)
711     except LDAPException:
712         pass
713
714     test(LDAPConnection.used_uids)
715     uids = connection.used_uids(minid, maxid)
716     if type(uids) is not list:
717         fail("list not returned")
718     success()
719
720     test(LDAPConnection.used_gids)
721     gids = connection.used_gids(minid, maxid)
722     if type(gids) is not list:
723         fail("list not returned")
724     success()
725
726     unusedids = []
727     for idnum in xrange(minid, maxid):
728         if not idnum in uids and not idnum in gids:
729             unusedids.append(idnum)
730
731     tuuid = unusedids.pop()
732     tugid = unusedids.pop()
733     eudata = {
734             'uid': [ tuname ],
735             'loginShell': [ tushell ],
736             'uidNumber': [ str(tuuid) ],
737             'gidNumber': [ str(tugid) ],
738             'gecos': [ tugecos ],
739             'homeDirectory': [ tuhome ],
740             'cn': [ turname ]
741             }
742
743     test(LDAPConnection.account_add)
744     connection.account_add(tuname, turname, tuuid, tugid, tuhome, tushell, tugecos)
745     success()
746
747     emdata = {
748             'uid': [ tmname ],
749             'cn': [ tmrname ],
750             'studentid': [ tmstudentid ],
751             'program': [ tmprogram ],
752             'description': [ tmdesc ],
753     }
754
755     test(LDAPConnection.member_add)
756     connection.member_add(tmname, tmrname, tmstudentid, tmprogram, tmdesc)
757     success()
758
759     tggid = unusedids.pop()
760     egdata = {
761             'cn': [ tgname ],
762             'gidNumber': [ str(tggid) ]
763             }
764
765     test(LDAPConnection.group_add)
766     connection.group_add(tgname, tggid)
767     success()
768
769     test(LDAPConnection.account_lookup)
770     udata = connection.account_lookup(tuname)
771     if udata: del udata['objectClass']
772     assert_equal(eudata, udata)
773     success()
774
775     test(LDAPConnection.member_lookup)
776     mdata = connection.member_lookup(tmname)
777     if mdata: del mdata['objectClass']
778     assert_equal(emdata, mdata)
779     success()
780
781     test(LDAPConnection.user_lookup)
782     udata = connection.user_lookup(tuname)
783     mdata = connection.user_lookup(tmname)
784     if udata: del udata['objectClass']
785     if mdata: del mdata['objectClass']
786     assert_equal(eudata, udata)
787     assert_equal(emdata, mdata)
788     success()
789
790     test(LDAPConnection.group_lookup)
791     gdata = connection.group_lookup(tgname)
792     if gdata: del gdata['objectClass']
793     assert_equal(egdata, gdata)
794     success()
795
796     test(LDAPConnection.account_search_id)
797     eulist = [ tuname ]
798     ulist = connection.account_search_id(tuuid).keys()
799     assert_equal(eulist, ulist)
800     success()
801
802     test(LDAPConnection.account_search_gid)
803     ulist = connection.account_search_gid(tugid)
804     if tuname not in ulist:
805         fail("%s not in %s" % (tuname, ulist))
806     success()
807
808     test(LDAPConnection.member_search_studentid)
809     mlist = connection.member_search_studentid(tmstudentid).keys()
810     emlist = [ tmname ]
811     assert_equal(emlist, mlist)
812     success()
813
814     test(LDAPConnection.member_search_name)
815     mlist = connection.member_search_name(tmrname)
816     if tmname not in mlist:
817         fail("%s not in %s" % (tmname, mlist))
818     success()
819
820     test(LDAPConnection.member_search_program)
821     mlist = connection.member_search_program(tmprogram)
822     if tmname not in mlist:
823         fail("%s not in %s" % (tmname, mlist))
824     success()
825
826     test(LDAPConnection.group_search_id)
827     glist = connection.group_search_id(tggid).keys()
828     eglist = [ tgname ]
829     assert_equal(eglist, glist)
830     success()
831
832     ecudata = connection.account_lookup(tuname)
833     ecudata['loginShell'] = [ cushell ]
834     ecudata['homeDirectory'] = [ cuhome ]
835     ecudata['cn'] = [ curname ]
836
837     test(LDAPConnection.user_modify)
838     connection.user_modify(tuname, ecudata)
839     cudata = connection.account_lookup(tuname)
840     assert_equal(ecudata, cudata)
841     success()
842
843     tmuid = unusedids.pop()
844     tmgid = unusedids.pop()
845     emadata = emdata.copy()
846     emadata.update({
847             'loginShell': [ cmshell ],
848             'uidNumber': [ str(tmuid) ],
849             'gidNumber': [ str(tmgid) ],
850             'gecos': [ cmgecos ],
851             'homeDirectory': [ cmhome ],
852             })
853
854     test(LDAPConnection.member_add_account)
855     connection.member_add_account(tmname, tmuid, tmuid, cmhome, cmshell, cmgecos)
856     success()
857
858     ecgdata = connection.group_lookup(tgname)
859     ecgdata['memberUid'] = [ tuname ]
860
861     test(LDAPConnection.group_modify)
862     connection.group_modify(tgname, ecgdata)
863     cgdata = connection.group_lookup(tgname)
864     assert_equal(ecgdata, cgdata)
865     success()
866
867     test(LDAPConnection.group_delete)
868     connection.group_delete(tgname)
869     success()
870
871     test(LDAPConnection.disconnect)
872     connection.disconnect()
873     success()