Moved files into their new locations prior to commit of 0.2.
[public/pyceo-broken.git] / pylib / csc / backends / ldapi.py
1 # $Id: ldapi.py 41 2006-12-29 04:22:31Z mspang $
2 """
3 LDAP Backend Interface
4
5 This module is intended to be a thin wrapper around LDAP operations.
6 Methods on the connection object correspond in a straightforward way
7 to LDAP queries and updates.
8
9 A LDAP entry is the most important component of a CSC UNIX account.
10 The entry contains the username, user id number, real name, shell,
11 and other important information. All non-local UNIX accounts must
12 have an LDAP entry, even if the account does not log in directly.
13
14 This module makes use of python-ldap, a Python module with bindings
15 to libldap, OpenLDAP's native C client library.
16 """
17 import ldap.modlist
18
19
20 class LDAPException(Exception):
21     """Exception class for LDAP-related errors."""
22
23
24 class LDAPConnection(object):
25     """
26     Connection to the LDAP directory. All directory
27     queries and updates are made via this class.
28
29     Exceptions: (all methods)
30         LDAPException - on directory query failure
31
32     Example:
33          connection = LDAPConnection()
34          connection.connect(...)
35
36          # make queries and updates, e.g.
37          connection.user_delete('mspang')
38
39          connection.disconnect()
40     """
41
42     def __init__(self):
43         self.ldap = None
44
45     
46     def connect(self, server, bind_dn, bind_pw, user_base, group_base):
47         """
48         Establish a connection to the LDAP Server.
49
50         Parameters:
51             server     - connection string (e.g. ldap://foo.com, ldaps://bar.com)
52             bind_dn    - distinguished name to bind to
53             bind_pw    - password of bind_dn
54             user_base  - base of the users subtree
55             group_base - baes of the group subtree
56
57         Example: connect('ldaps:///', 'cn=ceo,dc=csclub,dc=uwaterloo,dc=ca',
58                      'secret', 'ou=People,dc=csclub,dc=uwaterloo,dc=ca',
59                      'ou=Group,dc=csclub,dc=uwaterloo,dc=ca')
60         
61         """
62
63         if bind_pw == None: bind_pw = ''
64
65         try:
66
67             # open the connection
68             self.ldap = ldap.initialize(server)
69
70             # authenticate as ceo
71             self.ldap.simple_bind_s(bind_dn, bind_pw)
72
73         except ldap.LDAPError, e:
74             raise LDAPException("unable to connect: %s" % e)
75
76         self.user_base = user_base
77         self.group_base = group_base
78
79
80     def disconnect(self):
81         """Close the connection to the LDAP server."""
82         
83         if self.ldap:
84
85             # close connection
86             try:
87                 self.ldap.unbind_s()
88                 self.ldap = None
89             except ldap.LDAPError, e:
90                 raise LDAPException("unable to disconnect: %s" % e)
91
92
93     def connected(self):
94         """Determine whether the connection has been established."""
95
96         return self.ldap != None
97
98
99
100     ### Helper Methods ###
101
102     def lookup(self, dn):
103         """
104         Helper method to retrieve the attributes of an entry.
105
106         Parameters:
107             dn - the distinguished name of the directory entry
108
109         Returns: a dictionary of attributes of the matched dn, or
110                  None of the dn does not exist in the directory
111         """
112
113         # search for the specified dn
114         try:
115             matches = self.ldap.search_s(dn, ldap.SCOPE_BASE)
116         except ldap.NO_SUCH_OBJECT:
117             return None
118         except ldap.LDAPError, e:
119             raise LDAPException("unable to lookup dn %s: %s" % (dn, e))
120             
121         # this should never happen due to the nature of DNs
122         if len(matches) > 1:
123             raise LDAPException("duplicate dn in ldap: " + dn)
124         
125         # return the attributes of the single successful match
126         else:
127             match = matches[0]
128             match_dn, match_attributes = match
129             return match_attributes
130
131
132     
133     ### User-related Methods ###
134
135     def user_lookup(self, uid):
136         """
137         Retrieve the attributes of a user.
138
139         Parameters:
140             uid - the UNIX user accound name of the user
141
142         Returns: attributes of user with uid
143
144         Example: connection.user_lookup('mspang') ->
145                      { 'uid': 'mspang', 'uidNumber': 21292 ...}
146         """
147         
148         dn = 'uid=' + uid + ',' + self.user_base
149         return self.lookup(dn)
150         
151
152     def user_search(self, filter):
153         """
154         Helper for user searches.
155
156         Parameters:
157             filter - LDAP filter string to match users against
158
159         Returns: the list of uids matched
160         """
161
162         # search for entries that match the filter
163         try:
164             matches = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, filter)
165         except ldap.LDAPError, e:
166             raise LDAPException("user search failed: %s" % e)
167         
168         # list for uids found
169         uids = []
170         
171         for match in matches:
172             dn, attributes = match
173             
174             # uid is a required attribute of posixAccount
175             if not attributes.has_key('uid'):
176                 raise LDAPException(dn + ' (posixAccount) has no uid')
177             
178             # do not handle the case of multiple usernames in one entry (yet)
179             elif len(attributes['uid']) > 1:
180                 raise LDAPException(dn + ' (posixAccount) has multiple uids')
181             
182             # append the sole uid of this match to the list
183             uids.append( attributes['uid'][0] )
184
185         return uids
186
187
188     def user_search_id(self, uidNumber):
189         """
190         Retrieves a list of users with a certain UNIX uid number.
191
192         LDAP (or passwd for that matter) does not enforce any
193         restriction on the number of accounts that can have
194         a certain UID. Therefore this method returns a list of matches.
195
196         Parameters:
197             uidNumber - the user id of the accounts desired
198
199         Returns: the list of uids matched
200
201         Example: connection.user_search_id(21292) -> ['mspang']
202         """
203
204         # search for posixAccount entries with the specified uidNumber
205         filter = '(&(objectClass=posixAccount)(uidNumber=%d))' % uidNumber
206         return self.user_search(filter)
207
208
209     def user_search_gid(self, gidNumber):
210         """
211         Retrieves a list of users with a certain UNIX gid number.
212
213         Parameters:
214             gidNumber - the group id of the accounts desired
215
216         Returns: the list of uids matched
217         """
218
219         # search for posixAccount entries with the specified gidNumber
220         filter = '(&(objectClass=posixAccount)(gidNumber=%d))' % gidNumber
221         return self.user_search(filter)
222
223
224     def user_add(self, uid, cn, loginShell, uidNumber, gidNumber, homeDirectory, gecos):
225         """
226         Adds a user to the directory.
227
228         Parameters:
229             uid           - the UNIX username for the account
230             cn            - the full name of the member
231             userPassword  - password of the account (our setup does not use this)
232             loginShell    - login shell for the user
233             uidNumber     - the UNIX user id number
234             gidNumber     - the UNIX group id number
235             homeDirectory - home directory for the user
236             gecos         - comment field (usually stores miscellania)
237
238         Example: connection.user_add('mspang', 'Michael Spang',
239                      '/bin/bash', 21292, 100, '/users/mspang',
240                      'Michael Spang,,,')
241         """
242         
243         dn = 'uid=' + uid + ',' + self.user_base
244         attrs = {
245             'objectClass': [ 'top', 'account', 'posixAccount', 'shadowAccount' ],
246             'uid': [ uid ],
247             'cn': [ cn ],
248             'loginShell': [ loginShell ],
249             'uidNumber': [ str(uidNumber) ],
250             'gidNumber': [ str(gidNumber) ],
251             'homeDirectory': [ homeDirectory ],
252             'gecos': [ gecos ],
253         }
254
255         try:
256             modlist = ldap.modlist.addModlist(attrs)
257             self.ldap.add_s(dn, modlist)
258         except ldap.LDAPError, e:
259             raise LDAPException("unable to add: %s" % e)
260
261
262     def user_modify(self, uid, attrs):
263         """
264         Update user attributes in the directory.
265
266         Parameters:
267             uid   - username of the user to modify
268             entry - dictionary as returned by user_lookup() with changes to make.
269                     omitted attributes are DELETED.
270
271         Example: user = user_lookup('mspang')
272                  user['uidNumber'] = [ '0' ]
273                  connection.user_modify('mspang', user)
274         """
275
276         # distinguished name of the entry to modify
277         dn = 'uid=' + uid + ',' + self.user_base
278
279         # retrieve current state of user
280         old_user = self.user_lookup(uid)
281
282         try:
283             
284             # build list of modifications to make
285             changes = ldap.modlist.modifyModlist(old_user, attrs)
286
287             # apply changes
288             self.ldap.modify_s(dn, changes)
289
290         except ldap.LDAPError, e:
291             raise LDAPException("unable to modify: %s" % e)
292
293
294     def user_delete(self, uid):
295         """
296         Removes a user from the directory.
297
298         Parameters:
299             uid - the UNIX username of the account
300         
301         Example: connection.user_delete('mspang')
302         """
303         
304         try:
305             dn = 'uid=' + uid + ',' + self.user_base
306             self.ldap.delete_s(dn)
307         except ldap.LDAPError, e:
308             raise LDAPException("unable to delete: %s" % e)
309
310
311
312     ### Group-related Methods ###
313
314     def group_lookup(self, cn):
315         """
316         Retrieves the attributes of a group.
317
318         Parameters:
319             cn - the UNIX group name to lookup
320
321         Returns: attributes of group with cn
322
323         Example: connection.group_lookup('office') -> {
324                      'cn': 'office',
325                      'gidNumber', '1001',
326                      ...
327                  }
328         """
329         
330         dn = 'cn=' + cn + ',' + self.group_base
331         return self.lookup(dn)
332                                                                                     
333
334     def group_search_id(self, gidNumber):
335         """
336         Retrieves a list of groups with the specified UNIX group number.
337         
338         Parameters:
339             gidNumber - the group id of the groups desired
340
341         Returns: a list of groups with gid gidNumber
342
343         Example: connection.group_search_id(1001) -> ['office']
344         """
345
346         # search for posixAccount entries with the specified uidNumber
347         try:
348             filter = '(&(objectClass=posixGroup)(gidNumber=%d))' % gidNumber
349             matches = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, filter)
350         except ldap.LDAPError,e :
351             raise LDAPException("group search failed: %s" % e)
352
353         # list for groups found
354         group_cns = []
355
356         for match in matches:
357             dn, attributes = match
358
359             # cn is a required attribute of posixGroup
360             if not attributes.has_key('cn'):
361                 raise LDAPException(dn + ' (posixGroup) has no cn')
362
363             # do not handle the case of multiple cns for one group (yet)
364             elif len(attributes['cn']) > 1:
365                 raise LDAPException(dn + ' (posixGroup) has multiple cns')
366
367             # append the sole uid of this match to the list
368             group_cns.append( attributes['cn'][0] )
369
370         return group_cns
371
372
373     def group_add(self, cn, gidNumber):
374         """
375         Adds a group to the directory.
376
377         Parameters:
378             cn        - the name of the group
379             gidNumber - the number of the group
380
381         Example: connection.group_add('office', 1001)
382         """
383         
384         dn = 'cn=' + cn + ',' + self.group_base
385         attrs = {
386             'objectClass': [ 'top', 'posixGroup' ],
387             'cn': [ cn ],
388             'gidNumber': [ str(gidNumber) ],
389         }
390
391         try:
392             modlist = ldap.modlist.addModlist(attrs)
393             self.ldap.add_s(dn, modlist)
394         except ldap.LDAPError, e:
395             raise LDAPException("unable to add group: %s" % e)
396
397
398     def group_modify(self, cn, attrs):
399         """
400         Update group attributes in the directory.
401         
402         The only available updates are fairly destructive
403         (rename or renumber) but this method is provided
404         for completeness.
405
406         Parameters:
407             cn    - name of the group to modify
408             entry - dictionary as returned by group_lookup() with changes to make.
409                     omitted attributes are DELETED.
410
411         Example: group = group_lookup('office')
412                  group['gidNumber'] = [ str(connection.first_id(20000, 40000)) ]
413                  del group['memberUid']
414                  connection.group_modify('office', group)
415         """
416
417         # distinguished name of the entry to modify
418         dn = 'cn=' + cn + ',' + self.group_base
419
420         # retrieve current state of group
421         old_group = self.group_lookup(cn)
422
423         try:
424             
425             # build list of modifications to make
426             changes = ldap.modlist.modifyModlist(old_group, attrs)
427
428             # apply changes
429             self.ldap.modify_s(dn, changes)
430
431         except ldap.LDAPError, e:
432             raise LDAPException("unable to modify: %s" % e)
433
434
435     def group_delete(self, cn):
436         """
437         Removes a group from the directory."
438
439         Parameters:
440             cn - the name of the group
441
442         Example: connection.group_delete('office')
443         """
444         
445         try:
446             dn = 'cn=' + cn + ',' + self.group_base
447             self.ldap.delete_s(dn)
448         except ldap.LDAPError, e:
449             raise LDAPException("unable to delete group: %s" % e)
450
451
452     def group_members(self, cn):
453         """
454         Retrieves a group's members.
455
456         Parameters:
457             cn - the name of the group
458
459         Example: connection.group_members('office') ->
460                  ['sfflaw', 'jeperry', 'cschopf' ...]
461         """
462
463         group = self.group_lookup(cn)
464         return group.get('memberUid', None)
465
466
467     ### Miscellaneous Methods ###
468     
469     def first_id(self, minimum, maximum):
470         """
471         Determines the first available id within a range.
472
473         To be "available", there must be neither a user
474         with the id nor a group with the id.
475
476         Parameters:
477             minimum - smallest uid that may be returned
478             maximum - largest uid that may be returned
479
480         Returns: the id, or None if there are none available
481
482         Example: connection.first_id(20000, 40000) -> 20018
483         """
484
485         # compile a list of used uids
486         try:
487             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['uidNumber'])
488         except ldap.LDAPError, e:
489             raise LDAPException("search for uids failed: %s" % e)
490         uids = []
491         for user in users:
492             dn, attrs = user
493             uid = int(attrs['uidNumber'][0])
494             if minimum <= uid <= maximum:
495                 uids.append(uid)
496
497         # compile a list of used gids
498         try:
499             groups = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, '(objectClass=posixGroup)', ['gidNumber'])
500         except ldap.LDAPError, e:
501             raise LDAPException("search for gids failed: %s" % e)
502         gids = []
503         for group in groups:
504             dn, attrs = group
505             gid = int(attrs['gidNumber'][0])
506             if minimum <= gid <= maximum:
507                 gids.append(gid)
508
509         # iterate through ids and return the first available
510         for id in xrange(minimum, maximum+1):
511             if not id in uids and not id in gids:
512                 return id
513
514         # no suitable id was found
515         return None
516
517
518 ### Tests ###
519
520 if __name__ == '__main__':
521     
522     password_file = 'ldap.ceo'
523     server   = 'ldaps:///'
524     base_dn  = 'dc=csclub,dc=uwaterloo,dc=ca'
525     bind_dn  = 'cn=ceo,' + base_dn
526     user_dn  = 'ou=People,' + base_dn
527     group_dn = 'ou=Group,' + base_dn
528     bind_pw = open(password_file).readline().strip()
529
530     connection = LDAPConnection()
531     print "running disconnect()"
532     connection.disconnect()
533     print "running connect('%s', '%s', '%s', '%s', '%s')" % (server, bind_dn, '***', user_dn, group_dn)
534     connection.connect(server, bind_dn, bind_pw, user_dn, group_dn)
535     print "running user_lookup('mspang')", "->", "(%s)" % connection.user_lookup('mspang')['uidNumber'][0]
536     print "running user_search_id(21292)", "->", connection.user_search_id(21292)
537     print "running first_id(20000, 40000)", "->",
538     first_id = connection.first_id(20000, 40000)
539     print first_id
540     print "running group_add('testgroup', %d)" % first_id
541     try:
542         connection.group_add('testgroup', first_id)
543     except Exception, e:
544         print "FAILED: %s (continuing)" % e
545     print "running user_add('testuser', 'Test User', '/bin/false', %d, %d, '/home/null', 'Test User,,,')" % (first_id, first_id)
546     try:
547         connection.user_add('testuser', 'Test User', '/bin/false', first_id, first_id, '/home/null', 'Test User,,,')
548     except Exception, e:
549         print "FAILED: %s (continuing)" % e
550     print "running user_lookup('testuser')", "->",
551     user = connection.user_lookup('testuser')
552     print repr(connection.user_lookup('testuser')['cn'][0])
553     user['homeDirectory'] = ['/home/changed']
554     user['loginShell'] = ['/bin/true']
555     print "running user_modify(...)"
556     connection.user_modify('testuser', user)
557     print "running user_lookup('testuser')", "->",
558     user = connection.user_lookup('testuser')
559     print '(%s, %s)' % (user['homeDirectory'], user['loginShell'])
560     print "running group_lookup('testgroup')", "->",
561     group = connection.group_lookup('testgroup')
562     print group
563     print "running group_modify(...)"
564     group['gidNumber'] = [str(connection.first_id(20000, 40000))]
565     group['memberUid'] = [ str(first_id) ]
566     connection.group_modify('testgroup', group)
567     print "running group_lookup('testgroup')", "->",
568     group = connection.group_lookup('testgroup')
569     print group
570     print "running user_delete('testuser')"
571     connection.user_delete('testuser')
572     print "running group_delete('testgroup')"
573     connection.group_delete('testgroup')
574     print "running user_search_gid(100)", "->", "[" + ", ".join(map(repr,connection.user_search_gid(100)[:10])) + " ...]"
575     print "running group_members('office')", "->", "[" + ", ".join(map(repr,connection.group_members('office')[:10])) + " ...]"
576     print "running disconnect()"
577     connection.disconnect()