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.
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.
13 This module makes use of python-ldap, a Python module with bindings
14 to libldap, OpenLDAP's native C client library.
19 class LDAPException(Exception):
20 """Exception class for LDAP-related errors."""
23 class LDAPConnection(object):
25 Connection to the LDAP directory. All directory
26 queries and updates are made via this class.
28 Exceptions: (all methods)
29 LDAPException - on directory query failure
32 connection = LDAPConnection()
33 connection.connect(...)
35 # make queries and updates, e.g.
36 connection.user_delete('mspang')
38 connection.disconnect()
45 def connect(self, server, bind_dn, bind_pw, user_base, group_base):
47 Establish a connection to the LDAP Server.
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
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')
62 if bind_pw is None: bind_pw = ''
67 self.ldap = ldap.initialize(server)
70 self.ldap.simple_bind_s(bind_dn, bind_pw)
72 except ldap.LDAPError, e:
73 raise LDAPException("unable to connect: %s" % e)
75 self.user_base = user_base
76 self.group_base = group_base
80 """Close the connection to the LDAP server."""
88 except ldap.LDAPError, e:
89 raise LDAPException("unable to disconnect: %s" % e)
93 """Determine whether the connection has been established."""
95 return self.ldap is not None
99 ### Helper Methods ###
101 def lookup(self, dn, objectClass=None):
103 Helper method to retrieve the attributes of an entry.
106 dn - the distinguished name of the directory entry
108 Returns: a dictionary of attributes of the matched dn, or
109 None of the dn does not exist in the directory
112 if not self.connected(): raise LDAPException("Not connected!")
114 # search for the specified dn
117 search_filter = '(objectClass=%s)' % self.escape(objectClass)
118 matches = self.ldap.search_s(dn, ldap.SCOPE_BASE, search_filter)
120 matches = self.ldap.search_s(dn, ldap.SCOPE_BASE)
121 except ldap.NO_SUCH_OBJECT:
123 except ldap.LDAPError, e:
124 raise LDAPException("unable to lookup dn %s: %s" % (dn, e))
126 # this should never happen due to the nature of DNs
128 raise LDAPException("duplicate dn in ldap: " + dn)
130 # dn was found, but didn't match the objectClass filter
131 elif len(matches) < 1:
134 # return the attributes of the single successful match
136 match_dn, match_attributes = match
137 return match_attributes
141 ### User-related Methods ###
143 def user_lookup(self, uid, objectClass=None):
145 Retrieve the attributes of a user.
148 uid - the uid to look up
150 Returns: attributes of user with uid
153 dn = 'uid=' + uid + ',' + self.user_base
154 return self.lookup(dn, objectClass)
157 def user_search(self, search_filter, params):
159 Search for users with a filter.
162 search_filter - LDAP filter string to match users against
164 Returns: a dictionary mapping uids to attributes
167 if not self.connected(): raise LDAPException("Not connected!")
169 search_filter = search_filter % tuple(self.escape(x) for x in params)
171 # search for entries that match the filter
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)
178 for match in matches:
180 uid = attrs['uid'][0]
186 def user_modify(self, uid, attrs):
188 Update user attributes in the directory.
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.
195 Example: user = user_lookup('mspang')
196 user['uidNumber'] = [ '0' ]
197 connection.user_modify('mspang', user)
200 # distinguished name of the entry to modify
201 dn = 'uid=' + uid + ',' + self.user_base
203 # retrieve current state of user
204 old_user = self.user_lookup(uid)
208 # build list of modifications to make
209 changes = ldap.modlist.modifyModlist(old_user, attrs)
212 self.ldap.modify_s(dn, changes)
214 except ldap.LDAPError, e:
215 raise LDAPException("unable to modify: %s" % e)
218 def user_delete(self, uid):
220 Removes a user from the directory.
222 Example: connection.user_delete('mspang')
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)
233 ### Account-related Methods ###
235 def account_lookup(self, uid):
237 Retrieve the attributes of an account.
240 uid - the uid to look up
242 Returns: attributes of user with uid
245 return self.user_lookup(uid, 'posixAccount')
248 def account_search_id(self, uidNumber):
250 Retrieves a list of accounts with a certain UNIX uid number.
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.
257 uidNumber - the user id of the accounts desired
259 Returns: a dictionary mapping uids to attributes
261 Example: connection.account_search_id(21292) -> {'mspang': { ... }}
264 search_filter = '(&(objectClass=posixAccount)(uidNumber=%s))'
265 return self.user_search(search_filter, [ uidNumber ])
268 def account_search_gid(self, gidNumber):
270 Retrieves a list of accounts with a certain UNIX gid
271 number (search by default group).
273 Returns: a dictionary mapping uids to attributes
276 search_filter = '(&(objectClass=posixAccount)(gidNumber=%s))'
277 return self.user_search(search_filter, [ gidNumber ])
280 def account_add(self, uid, cn, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None, description=None, update=False):
282 Adds a user account to the directory.
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
295 Example: connection.user_add('mspang', 'Michael Spang',
296 21292, 100, '/users/mspang', '/bin/bash',
300 if not self.connected(): raise LDAPException("Not connected!")
302 dn = 'uid=' + uid + ',' + self.user_base
304 'objectClass': [ 'top', 'account', 'posixAccount', 'shadowAccount' ],
307 'loginShell': [ loginShell ],
308 'uidNumber': [ str(uidNumber) ],
309 'gidNumber': [ str(gidNumber) ],
310 'homeDirectory': [ homeDirectory ],
315 attrs['loginShell'] = [ loginShell ]
317 attrs['description'] = [ description ]
321 old_entry = self.user_lookup(uid)
322 if old_entry and 'posixAccount' not in old_entry['objectClass'] and update:
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')
330 modlist = ldap.modlist.modifyModlist(old_entry, attrs)
331 self.ldap.modify_s(dn, modlist)
335 modlist = ldap.modlist.addModlist(attrs)
336 self.ldap.add_s(dn, modlist)
338 except ldap.LDAPError, e:
339 raise LDAPException("unable to add: %s" % e)
343 ### Group-related Methods ###
345 def group_lookup(self, cn):
347 Retrieves the attributes of a group.
350 cn - the UNIX group name to lookup
352 Returns: attributes of the group's LDAP entry
354 Example: connection.group_lookup('office') -> {
361 dn = 'cn=' + cn + ',' + self.group_base
362 return self.lookup(dn, 'posixGroup')
365 def group_search_id(self, gidNumber):
367 Retrieves a list of groups with the specified UNIX group number.
369 Returns: a list of groups with gid gidNumber
371 Example: connection.group_search_id(1001) -> ['office']
374 if not self.connected(): raise LDAPException("Not connected!")
376 # search for posixAccount entries with the specified uidNumber
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)
383 # list for groups found
387 for match in matches:
395 def group_add(self, cn, gidNumber, description=None):
397 Adds a group to the directory.
399 Example: connection.group_add('office', 1001, 'Office Staff')
402 if not self.connected(): raise LDAPException("Not connected!")
404 dn = 'cn=' + cn + ',' + self.group_base
406 'objectClass': [ 'top', 'posixGroup' ],
408 'gidNumber': [ str(gidNumber) ],
411 attrs['description'] = description
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)
420 def group_modify(self, cn, attrs):
422 Update group attributes in the directory.
424 The only available updates are fairly destructive (rename or renumber)
425 but this method is provided for completeness.
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.
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)
438 if not self.connected(): raise LDAPException("Not connected!")
440 # distinguished name of the entry to modify
441 dn = 'cn=' + cn + ',' + self.group_base
443 # retrieve current state of group
444 old_group = self.group_lookup(cn)
448 # build list of modifications to make
449 changes = ldap.modlist.modifyModlist(old_group, attrs)
452 self.ldap.modify_s(dn, changes)
454 except ldap.LDAPError, e:
455 raise LDAPException("unable to modify: %s" % e)
458 def group_delete(self, cn):
460 Removes a group from the directory."
462 Example: connection.group_delete('office')
465 if not self.connected(): raise LDAPException("Not connected!")
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)
475 ### Member-related Methods ###
477 def member_lookup(self, uid):
479 Retrieve the attributes of a member. This method will only return
480 results that have the objectClass 'member'.
483 uid - the username to look up
485 Returns: attributes of member with uid
487 Example: connection.member_lookup('mspang') ->
488 { 'uid': 'mspang', 'uidNumber': 21292 ...}
491 if not self.connected(): raise LDAPException("Not connected!")
493 dn = 'uid=' + uid + ',' + self.user_base
494 return self.lookup(dn, 'member')
497 def member_search_studentid(self, studentid):
499 Retrieves a list of members with a certain studentid.
501 Returns: a dictionary mapping uids to attributes
504 search_filter = '(&(objectClass=member)(studentid=%s))'
505 return self.user_search(search_filter, [ studentid ] )
508 def member_search_name(self, name):
510 Retrieves a list of members with the specified name (fuzzy).
512 Returns: a dictionary mapping uids to attributes
515 search_filter = '(&(objectClass=member)(cn~=%s))'
516 return self.user_search(search_filter, [ name ] )
519 def member_search_term(self, term):
521 Retrieves a list of members who were registered in a certain term.
523 Returns: a dictionary mapping uids to attributes
526 search_filter = '(&(objectClass=member)(term=%s))'
527 return self.user_search(search_filter, [ term ])
530 def member_search_program(self, program):
532 Retrieves a list of members in a certain program (fuzzy).
534 Returns: a dictionary mapping uids to attributes
537 search_filter = '(&(objectClass=member)(program~=%s))'
538 return self.user_search(search_filter, [ program ])
541 def member_add(self, uid, cn, studentid, program=None, description=None):
543 Adds a member to the directory.
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
553 dn = 'uid=' + uid + ',' + self.user_base
555 'objectClass': [ 'top', 'account', 'member' ],
558 'studentid': [ studentid ],
562 attrs['program'] = [ program ]
564 attrs['description'] = [ description ]
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)
573 def member_add_account(self, uid, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None):
575 Adds login privileges to a member.
578 return self.account_add(uid, None, uidNumber, gidNumber, homeDirectory, loginShell, gecos, None, True)
582 ### Miscellaneous Methods ###
584 def escape(self, value):
586 Escapes special characters in a value so that it may be safely inserted
587 into an LDAP search filter.
591 value = value.replace('\\', '\\5c').replace('*', '\\2a')
592 value = value.replace('(', '\\28').replace(')', '\\29')
593 value = value.replace('\x00', '\\00')
597 def used_uids(self, minimum=None, maximum=None):
599 Compiles a list of used UIDs in a range.
602 minimum - smallest uid to return in the list
603 maximum - largest uid to return in the list
605 Returns: list of integer uids
607 Example: connection.used_uids(20000, 40000) -> [20000, 20001, ...]
610 if not self.connected(): raise LDAPException("Not connected!")
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)
620 uid = int(attrs['uidNumber'][0])
621 if (not minimum or uid >= minimum) and (not maximum or uid <= maximum):
627 def used_gids(self, minimum=None, maximum=None):
629 Compiles a list of used GIDs in a range.
632 minimum - smallest gid to return in the list
633 maximum - largest gid to return in the list
635 Returns: list of integer gids
637 Example: connection.used_gids(20000, 40000) -> [20000, 20001, ...]
640 if not self.connected(): raise LDAPException("Not connected!")
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)
650 gid = int(attrs['gidNumber'][0])
651 if (not minimum or gid >= minimum) and (not maximum or gid <= maximum):
660 if __name__ == '__main__':
662 from csc.common.test import *
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]
674 # t=test u=user g=group c=changed r=real e=expected
676 turname = 'Test User'
677 tuhome = '/home/testuser'
678 tushell = '/bin/false'
679 tugecos = 'Test User,,,'
681 tmname = 'testmember'
682 tmrname = 'Test Member'
683 tmstudentid = '99999999'
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,,,'
694 connection = LDAPConnection()
697 test(LDAPConnection.disconnect)
698 connection.disconnect()
701 test(LDAPConnection.connect)
702 connection.connect(srvurl, binddn, bindpw, ubase, gbase)
703 if not connection.connected():
704 fail("not connected")
708 connection.user_delete(tuname)
709 connection.user_delete(tmname)
710 connection.group_delete(tgname)
711 except LDAPException:
714 test(LDAPConnection.used_uids)
715 uids = connection.used_uids(minid, maxid)
716 if type(uids) is not list:
717 fail("list not returned")
720 test(LDAPConnection.used_gids)
721 gids = connection.used_gids(minid, maxid)
722 if type(gids) is not list:
723 fail("list not returned")
727 for idnum in xrange(minid, maxid):
728 if not idnum in uids and not idnum in gids:
729 unusedids.append(idnum)
731 tuuid = unusedids.pop()
732 tugid = unusedids.pop()
735 'loginShell': [ tushell ],
736 'uidNumber': [ str(tuuid) ],
737 'gidNumber': [ str(tugid) ],
738 'gecos': [ tugecos ],
739 'homeDirectory': [ tuhome ],
743 test(LDAPConnection.account_add)
744 connection.account_add(tuname, turname, tuuid, tugid, tuhome, tushell, tugecos)
750 'studentid': [ tmstudentid ],
751 'program': [ tmprogram ],
752 'description': [ tmdesc ],
755 test(LDAPConnection.member_add)
756 connection.member_add(tmname, tmrname, tmstudentid, tmprogram, tmdesc)
759 tggid = unusedids.pop()
762 'gidNumber': [ str(tggid) ]
765 test(LDAPConnection.group_add)
766 connection.group_add(tgname, tggid)
769 test(LDAPConnection.account_lookup)
770 udata = connection.account_lookup(tuname)
771 if udata: del udata['objectClass']
772 assert_equal(eudata, udata)
775 test(LDAPConnection.member_lookup)
776 mdata = connection.member_lookup(tmname)
777 if mdata: del mdata['objectClass']
778 assert_equal(emdata, mdata)
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)
790 test(LDAPConnection.group_lookup)
791 gdata = connection.group_lookup(tgname)
792 if gdata: del gdata['objectClass']
793 assert_equal(egdata, gdata)
796 test(LDAPConnection.account_search_id)
798 ulist = connection.account_search_id(tuuid).keys()
799 assert_equal(eulist, ulist)
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))
808 test(LDAPConnection.member_search_studentid)
809 mlist = connection.member_search_studentid(tmstudentid).keys()
811 assert_equal(emlist, mlist)
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))
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))
826 test(LDAPConnection.group_search_id)
827 glist = connection.group_search_id(tggid).keys()
829 assert_equal(eglist, glist)
832 ecudata = connection.account_lookup(tuname)
833 ecudata['loginShell'] = [ cushell ]
834 ecudata['homeDirectory'] = [ cuhome ]
835 ecudata['cn'] = [ curname ]
837 test(LDAPConnection.user_modify)
838 connection.user_modify(tuname, ecudata)
839 cudata = connection.account_lookup(tuname)
840 assert_equal(ecudata, cudata)
843 tmuid = unusedids.pop()
844 tmgid = unusedids.pop()
845 emadata = emdata.copy()
847 'loginShell': [ cmshell ],
848 'uidNumber': [ str(tmuid) ],
849 'gidNumber': [ str(tmgid) ],
850 'gecos': [ cmgecos ],
851 'homeDirectory': [ cmhome ],
854 test(LDAPConnection.member_add_account)
855 connection.member_add_account(tmname, tmuid, tmuid, cmhome, cmshell, cmgecos)
858 ecgdata = connection.group_lookup(tgname)
859 ecgdata['memberUid'] = [ tuname ]
861 test(LDAPConnection.group_modify)
862 connection.group_modify(tgname, ecgdata)
863 cgdata = connection.group_lookup(tgname)
864 assert_equal(ecgdata, cgdata)
867 test(LDAPConnection.group_delete)
868 connection.group_delete(tgname)
871 test(LDAPConnection.disconnect)
872 connection.disconnect()