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', 'group' ],
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_name(self, name):
499 Retrieves a list of members with the specified name (fuzzy).
501 Returns: a dictionary mapping uids to attributes
504 search_filter = '(&(objectClass=member)(cn~=%s))'
505 return self.user_search(search_filter, [ name ] )
508 def member_search_term(self, term):
510 Retrieves a list of members who were registered in a certain term.
512 Returns: a dictionary mapping uids to attributes
515 search_filter = '(&(objectClass=member)(term=%s))'
516 return self.user_search(search_filter, [ term ])
519 def member_search_program(self, program):
521 Retrieves a list of members in a certain program (fuzzy).
523 Returns: a dictionary mapping uids to attributes
526 search_filter = '(&(objectClass=member)(program~=%s))'
527 return self.user_search(search_filter, [ program ])
530 def member_add(self, uid, cn, program=None, description=None):
532 Adds a member to the directory.
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
541 dn = 'uid=' + uid + ',' + self.user_base
543 'objectClass': [ 'top', 'account', 'member' ],
549 attrs['program'] = [ program ]
551 attrs['description'] = [ description ]
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)
560 def member_add_account(self, uid, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None):
562 Adds login privileges to a member.
565 return self.account_add(uid, None, uidNumber, gidNumber, homeDirectory, loginShell, gecos, None, True)
569 ### Miscellaneous Methods ###
571 def escape(self, value):
573 Escapes special characters in a value so that it may be safely inserted
574 into an LDAP search filter.
578 value = value.replace('\\', '\\5c').replace('*', '\\2a')
579 value = value.replace('(', '\\28').replace(')', '\\29')
580 value = value.replace('\x00', '\\00')
584 def used_uids(self, minimum=None, maximum=None):
586 Compiles a list of used UIDs in a range.
589 minimum - smallest uid to return in the list
590 maximum - largest uid to return in the list
592 Returns: list of integer uids
594 Example: connection.used_uids(20000, 40000) -> [20000, 20001, ...]
597 if not self.connected(): raise LDAPException("Not connected!")
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)
607 uid = int(attrs['uidNumber'][0])
608 if (not minimum or uid >= minimum) and (not maximum or uid <= maximum):
614 def used_gids(self, minimum=None, maximum=None):
616 Compiles a list of used GIDs in a range.
619 minimum - smallest gid to return in the list
620 maximum - largest gid to return in the list
622 Returns: list of integer gids
624 Example: connection.used_gids(20000, 40000) -> [20000, 20001, ...]
627 if not self.connected(): raise LDAPException("Not connected!")
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)
637 gid = int(attrs['gidNumber'][0])
638 if (not minimum or gid >= minimum) and (not maximum or gid <= maximum):
647 if __name__ == '__main__':
649 from csc.common.test import *
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]
661 # t=test u=user g=group c=changed r=real e=expected
663 turname = 'Test User'
664 tuhome = '/home/testuser'
665 tushell = '/bin/false'
666 tugecos = 'Test User,,,'
668 tmname = 'testmember'
669 tmrname = 'Test Member'
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,,,'
680 connection = LDAPConnection()
683 test(LDAPConnection.disconnect)
684 connection.disconnect()
687 test(LDAPConnection.connect)
688 connection.connect(srvurl, binddn, bindpw, ubase, gbase)
689 if not connection.connected():
690 fail("not connected")
694 connection.user_delete(tuname)
695 connection.user_delete(tmname)
696 connection.group_delete(tgname)
697 except LDAPException:
700 test(LDAPConnection.used_uids)
701 uids = connection.used_uids(minid, maxid)
702 if type(uids) is not list:
703 fail("list not returned")
706 test(LDAPConnection.used_gids)
707 gids = connection.used_gids(minid, maxid)
708 if type(gids) is not list:
709 fail("list not returned")
713 for idnum in xrange(minid, maxid):
714 if not idnum in uids and not idnum in gids:
715 unusedids.append(idnum)
717 tuuid = unusedids.pop()
718 tugid = unusedids.pop()
721 'loginShell': [ tushell ],
722 'uidNumber': [ str(tuuid) ],
723 'gidNumber': [ str(tugid) ],
724 'gecos': [ tugecos ],
725 'homeDirectory': [ tuhome ],
729 test(LDAPConnection.account_add)
730 connection.account_add(tuname, turname, tuuid, tugid, tuhome, tushell, tugecos)
736 'program': [ tmprogram ],
737 'description': [ tmdesc ],
740 test(LDAPConnection.member_add)
741 connection.member_add(tmname, tmrname, tmprogram, tmdesc)
744 tggid = unusedids.pop()
747 'gidNumber': [ str(tggid) ]
750 test(LDAPConnection.group_add)
751 connection.group_add(tgname, tggid)
754 test(LDAPConnection.account_lookup)
755 udata = connection.account_lookup(tuname)
756 if udata: del udata['objectClass']
757 assert_equal(eudata, udata)
760 test(LDAPConnection.member_lookup)
761 mdata = connection.member_lookup(tmname)
762 if mdata: del mdata['objectClass']
763 assert_equal(emdata, mdata)
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)
775 test(LDAPConnection.group_lookup)
776 gdata = connection.group_lookup(tgname)
777 if gdata: del gdata['objectClass']
778 assert_equal(egdata, gdata)
781 test(LDAPConnection.account_search_id)
783 ulist = connection.account_search_id(tuuid).keys()
784 assert_equal(eulist, ulist)
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))
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))
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))
805 test(LDAPConnection.group_search_id)
806 glist = connection.group_search_id(tggid).keys()
808 assert_equal(eglist, glist)
811 ecudata = connection.account_lookup(tuname)
812 ecudata['loginShell'] = [ cushell ]
813 ecudata['homeDirectory'] = [ cuhome ]
814 ecudata['cn'] = [ curname ]
816 test(LDAPConnection.user_modify)
817 connection.user_modify(tuname, ecudata)
818 cudata = connection.account_lookup(tuname)
819 assert_equal(ecudata, cudata)
822 tmuid = unusedids.pop()
823 tmgid = unusedids.pop()
824 emadata = emdata.copy()
826 'loginShell': [ cmshell ],
827 'uidNumber': [ str(tmuid) ],
828 'gidNumber': [ str(tmgid) ],
829 'gecos': [ cmgecos ],
830 'homeDirectory': [ cmhome ],
833 test(LDAPConnection.member_add_account)
834 connection.member_add_account(tmname, tmuid, tmuid, cmhome, cmshell, cmgecos)
837 ecgdata = connection.group_lookup(tgname)
838 ecgdata['memberUid'] = [ tuname ]
840 test(LDAPConnection.group_modify)
841 connection.group_modify(tgname, ecgdata)
842 cgdata = connection.group_lookup(tgname)
843 assert_equal(ecgdata, cgdata)
846 test(LDAPConnection.group_delete)
847 connection.group_delete(tgname)
850 test(LDAPConnection.disconnect)
851 connection.disconnect()