New release (version 0.2).
[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 as ceo
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):
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         # search for the specified dn
113         try:
114             matches = self.ldap.search_s(dn, ldap.SCOPE_BASE)
115         except ldap.NO_SUCH_OBJECT:
116             return None
117         except ldap.LDAPError, e:
118             raise LDAPException("unable to lookup dn %s: %s" % (dn, e))
119             
120         # this should never happen due to the nature of DNs
121         if len(matches) > 1:
122             raise LDAPException("duplicate dn in ldap: " + dn)
123         
124         # return the attributes of the single successful match
125         else:
126             match = matches[0]
127             match_dn, match_attributes = match
128             return match_attributes
129
130
131     
132     ### User-related Methods ###
133
134     def user_lookup(self, uid):
135         """
136         Retrieve the attributes of a user.
137
138         Parameters:
139             uid - the UNIX username to look up
140
141         Returns: attributes of user with uid
142
143         Example: connection.user_lookup('mspang') ->
144                      { 'uid': 'mspang', 'uidNumber': 21292 ...}
145         """
146
147         if not self.connected(): raise LDAPException("Not connected!")
148         
149         dn = 'uid=' + uid + ',' + self.user_base
150         return self.lookup(dn)
151         
152
153     def user_search(self, search_filter):
154         """
155         Helper for user searches.
156
157         Parameters:
158             search_filter - LDAP filter string to match users against
159
160         Returns: the list of uids matched (usernames)
161         """
162
163         # search for entries that match the filter
164         try:
165             matches = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, search_filter)
166         except ldap.LDAPError, e:
167             raise LDAPException("user search failed: %s" % e)
168         
169         # list for uids found
170         uids = []
171         
172         for match in matches:
173             dn, attributes = match
174             
175             # uid is a required attribute of posixAccount
176             if not attributes.has_key('uid'):
177                 raise LDAPException(dn + ' (posixAccount) has no uid')
178             
179             # do not handle the case of multiple usernames in one entry (yet)
180             elif len(attributes['uid']) > 1:
181                 raise LDAPException(dn + ' (posixAccount) has multiple uids')
182             
183             # append the sole uid of this match to the list
184             uids.append( attributes['uid'][0] )
185
186         return uids
187
188
189     def user_search_id(self, uidNumber):
190         """
191         Retrieves a list of users with a certain UNIX uid number.
192
193         LDAP (or passwd for that matter) does not enforce any
194         restriction on the number of accounts that can have
195         a certain UID. Therefore this method returns a list of matches.
196
197         Parameters:
198             uidNumber - the user id of the accounts desired
199
200         Returns: the list of uids matched (usernames)
201
202         Example: connection.user_search_id(21292) -> ['mspang']
203         """
204
205         # search for posixAccount entries with the specified uidNumber
206         search_filter = '(&(objectClass=posixAccount)(uidNumber=%d))' % uidNumber
207         return self.user_search(search_filter)
208
209
210     def user_search_gid(self, gidNumber):
211         """
212         Retrieves a list of users with a certain UNIX gid
213         number (search by default group).
214
215         Returns: the list of uids matched (usernames)
216         """
217
218         # search for posixAccount entries with the specified gidNumber
219         search_filter = '(&(objectClass=posixAccount)(gidNumber=%d))' % gidNumber
220         return self.user_search(search_filter)
221
222
223     def user_add(self, uid, cn, uidNumber, gidNumber, homeDirectory, loginShell=None, gecos=None, description=None):
224         """
225         Adds a user to the directory.
226
227         Parameters:
228             uid           - the UNIX username for the account
229             cn            - the real name of the member
230             uidNumber     - the UNIX user id number
231             gidNumber     - the UNIX group id number (default group)
232             homeDirectory - home directory for the user
233             loginShell    - login shell for the user
234             gecos         - comment field (usually stores name etc)
235             description   - description field (optional and unimportant)
236
237         Example: connection.user_add('mspang', 'Michael Spang',
238                      21292, 100, '/users/mspang', '/bin/bash', 
239                      'Michael Spang,,,')
240         """
241         
242         dn = 'uid=' + uid + ',' + self.user_base
243         attrs = {
244             'objectClass': [ 'top', 'account', 'posixAccount', 'shadowAccount' ],
245             'uid': [ uid ],
246             'cn': [ cn ],
247             'loginShell': [ loginShell ],
248             'uidNumber': [ str(uidNumber) ],
249             'gidNumber': [ str(gidNumber) ],
250             'homeDirectory': [ homeDirectory ],
251             'gecos': [ gecos ],
252         }
253         
254         if loginShell:
255             attrs['loginShell'] = loginShell
256         if description:
257             attrs['description'] = [ description ]
258
259         try:
260             modlist = ldap.modlist.addModlist(attrs)
261             self.ldap.add_s(dn, modlist)
262         except ldap.LDAPError, e:
263             raise LDAPException("unable to add: %s" % e)
264
265
266     def user_modify(self, uid, attrs):
267         """
268         Update user attributes in the directory.
269
270         Parameters:
271             uid   - username of the user to modify
272             attrs - dictionary as returned by user_lookup() with changes to make.
273                     omitted attributes are DELETED.
274
275         Example: user = user_lookup('mspang')
276                  user['uidNumber'] = [ '0' ]
277                  connection.user_modify('mspang', user)
278         """
279
280         # distinguished name of the entry to modify
281         dn = 'uid=' + uid + ',' + self.user_base
282
283         # retrieve current state of user
284         old_user = self.user_lookup(uid)
285
286         try:
287             
288             # build list of modifications to make
289             changes = ldap.modlist.modifyModlist(old_user, attrs)
290
291             # apply changes
292             self.ldap.modify_s(dn, changes)
293
294         except ldap.LDAPError, e:
295             raise LDAPException("unable to modify: %s" % e)
296
297
298     def user_delete(self, uid):
299         """
300         Removes a user from the directory.
301
302         Example: connection.user_delete('mspang')
303         """
304         
305         try:
306             dn = 'uid=' + uid + ',' + self.user_base
307             self.ldap.delete_s(dn)
308         except ldap.LDAPError, e:
309             raise LDAPException("unable to delete: %s" % e)
310
311
312
313     ### Group-related Methods ###
314
315     def group_lookup(self, cn):
316         """
317         Retrieves the attributes of a group.
318
319         Parameters:
320             cn - the UNIX group name to lookup
321
322         Returns: attributes of the group's LDAP entry
323
324         Example: connection.group_lookup('office') -> {
325                      'cn': 'office',
326                      'gidNumber', '1001',
327                      ...
328                  }
329         """
330         
331         dn = 'cn=' + cn + ',' + self.group_base
332         return self.lookup(dn)
333                                                                                     
334
335     def group_search_id(self, gidNumber):
336         """
337         Retrieves a list of groups with the specified UNIX group number.
338         
339         Returns: a list of groups with gid gidNumber
340
341         Example: connection.group_search_id(1001) -> ['office']
342         """
343
344         # search for posixAccount entries with the specified uidNumber
345         try:
346             search_filter = '(&(objectClass=posixGroup)(gidNumber=%d))' % gidNumber
347             matches = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, search_filter)
348         except ldap.LDAPError,e :
349             raise LDAPException("group search failed: %s" % e)
350
351         # list for groups found
352         group_cns = []
353
354         for match in matches:
355             dn, attributes = match
356
357             # cn is a required attribute of posixGroup
358             if not attributes.has_key('cn'):
359                 raise LDAPException(dn + ' (posixGroup) has no cn')
360
361             # do not handle the case of multiple cns for one group (yet)
362             elif len(attributes['cn']) > 1:
363                 raise LDAPException(dn + ' (posixGroup) has multiple cns')
364
365             # append the sole uid of this match to the list
366             group_cns.append( attributes['cn'][0] )
367
368         return group_cns
369
370
371     def group_add(self, cn, gidNumber, description=None):
372         """
373         Adds a group to the directory.
374
375         Example: connection.group_add('office', 1001, 'Office Staff')
376         """
377         
378         dn = 'cn=' + cn + ',' + self.group_base
379         attrs = {
380             'objectClass': [ 'top', 'posixGroup' ],
381             'cn': [ cn ],
382             'gidNumber': [ str(gidNumber) ],
383         }
384         if description:
385             attrs['description'] = description
386
387         try:
388             modlist = ldap.modlist.addModlist(attrs)
389             self.ldap.add_s(dn, modlist)
390         except ldap.LDAPError, e:
391             raise LDAPException("unable to add group: %s" % e)
392
393
394     def group_modify(self, cn, attrs):
395         """
396         Update group attributes in the directory.
397         
398         The only available updates are fairly destructive (rename or renumber)
399         but this method is provided for completeness.
400
401         Parameters:
402             cn    - name of the group to modify
403             entry - dictionary as returned by group_lookup() with changes to make.
404                     omitted attributes are DELETED.
405
406         Example: group = group_lookup('office')
407                  group['gidNumber'] = [ str(connection.first_id(20000, 40000)) ]
408                  del group['memberUid']
409                  connection.group_modify('office', group)
410         """
411
412         # distinguished name of the entry to modify
413         dn = 'cn=' + cn + ',' + self.group_base
414
415         # retrieve current state of group
416         old_group = self.group_lookup(cn)
417
418         try:
419             
420             # build list of modifications to make
421             changes = ldap.modlist.modifyModlist(old_group, attrs)
422
423             # apply changes
424             self.ldap.modify_s(dn, changes)
425
426         except ldap.LDAPError, e:
427             raise LDAPException("unable to modify: %s" % e)
428
429
430     def group_delete(self, cn):
431         """
432         Removes a group from the directory."
433
434         Example: connection.group_delete('office')
435         """
436         
437         try:
438             dn = 'cn=' + cn + ',' + self.group_base
439             self.ldap.delete_s(dn)
440         except ldap.LDAPError, e:
441             raise LDAPException("unable to delete group: %s" % e)
442
443
444     ### Miscellaneous Methods ###
445
446     def used_uids(self, minimum=None, maximum=None):
447         """
448         Compiles a list of used UIDs in a range.
449
450         Parameters:
451             minimum - smallest uid to return in the list
452             maximum - largest uid to return in the list
453
454         Returns: list of integer uids
455
456         Example: connection.used_uids(20000, 40000) -> [20000, 20001, ...]
457         """
458
459         try:
460             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['uidNumber'])
461         except ldap.LDAPError, e:
462             raise LDAPException("search for uids failed: %s" % e)
463         
464         uids = []
465         for user in users:
466             dn, attrs = user
467             uid = int(attrs['uidNumber'][0])
468             if (not minimum or uid >= minimum) and (not maximum or uid <= maximum):
469                 uids.append(uid)
470
471         return uids
472             
473     
474     def used_gids(self, minimum=None, maximum=None):
475         """
476         Compiles a list of used GIDs in a range.
477
478         Parameters:
479             minimum - smallest gid to return in the list
480             maximum - largest gid to return in the list
481
482         Returns: list of integer gids
483
484         Example: connection.used_gids(20000, 40000) -> [20000, 20001, ...]
485         """
486
487         try:
488             users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['gidNumber'])
489         except ldap.LDAPError, e:
490             raise LDAPException("search for gids failed: %s" % e)
491         
492         gids = []
493         for user in users:
494             dn, attrs = user
495             gid = int(attrs['gidNumber'][0])
496             if (not minimum or gid >= minimum) and (not maximum or gid <= maximum):
497                 gids.append(gid)
498
499         return gids
500
501
502
503 ### Tests ###
504
505 if __name__ == '__main__':
506     
507     from csc.common.test import *
508
509     conffile = '/etc/csc/ldap.cf'
510     cfg = dict([map(str.strip, a.split("=", 1)) for a in map(str.strip, open(conffile).read().split("\n")) if "=" in a ]) 
511     srvurl = cfg['server_url'][1:-1]
512     binddn = cfg['admin_bind_dn'][1:-1]
513     bindpw = cfg['admin_bind_pw'][1:-1]
514     ubase = cfg['users_base'][1:-1]
515     gbase = cfg['groups_base'][1:-1]
516     minid = 99999000
517     maxid = 100000000
518
519     # t=test u=user g=group c=changed r=real e=expected
520     tuname = 'testuser'
521     turname = 'Test User'
522     tuhome = '/home/testuser'
523     tushell = '/bin/false'
524     tugecos = 'Test User,,,'
525     tgname = 'testgroup'
526     cushell = '/bin/true'
527     cuhome = '/home/changed'
528     curname = 'Test Modified User'
529
530     test("LDAPConnection()")
531     connection = LDAPConnection()
532     success()
533
534     test("disconnect()")
535     connection.disconnect()
536     success()
537
538     test("connect()")
539     connection.connect(srvurl, binddn, bindpw, ubase, gbase)
540     if not connection.connected():
541         fail("not connected")
542     success()
543
544     try:
545         connection.user_delete(tuname)
546         connection.group_delete(tgname)
547     except LDAPException:
548         pass
549
550     test("used_uids()")
551     uids = connection.used_uids(minid, maxid)
552     if type(uids) is not list:
553         fail("list not returned")
554     success()
555
556     test("used_gids()")
557     gids = connection.used_gids(minid, maxid)
558     if type(gids) is not list:
559         fail("list not returned")
560     success()
561
562     unusedids = []
563     for idnum in xrange(minid, maxid):
564         if not idnum in uids and not idnum in gids:
565             unusedids.append(idnum)
566
567     tuuid = unusedids.pop()
568     tugid = unusedids.pop()
569     eudata = {
570             'uid': [ tuname ],
571             'loginShell': [ tushell ],
572             'uidNumber': [ str(tuuid) ],
573             'gidNumber': [ str(tugid) ],
574             'gecos': [ tugecos ],
575             'homeDirectory': [ tuhome ],
576             'cn': [ turname ]
577             }
578
579     test("user_add()")
580     connection.user_add(tuname, turname, tuuid, tugid, tuhome, tushell, tugecos)
581     success()
582
583     tggid = unusedids.pop()
584     egdata = {
585             'cn': [ tgname ],
586             'gidNumber': [ str(tggid) ]
587             }
588
589     test("group_add()")
590     connection.group_add(tgname, tggid)
591     success()
592
593     test("user_lookup()")
594     udata = connection.user_lookup(tuname)
595     del udata['objectClass']
596     assert_equal(eudata, udata)
597     success()
598
599     test("group_lookup()")
600     gdata = connection.group_lookup(tgname)
601     del gdata['objectClass']
602     assert_equal(egdata, gdata)
603     success()
604
605     test("user_search_id()")
606     eulist = [ tuname ]
607     ulist = connection.user_search_id(tuuid)
608     assert_equal(eulist, ulist)
609     success()
610
611     test("user_search_gid()")
612     ulist = connection.user_search_gid(tugid)
613     if tuname not in ulist:
614         fail("(%s) not in (%s)" % (tuname, ulist))
615     success()
616
617     ecudata = connection.user_lookup(tuname)
618     ecudata['loginShell'] = [ cushell ]
619     ecudata['homeDirectory'] = [ cuhome ]
620     ecudata['cn'] = [ curname ]
621
622     test("user_modify")
623     connection.user_modify(tuname, ecudata)
624     cudata = connection.user_lookup(tuname)
625     assert_equal(ecudata, cudata)
626     success()
627
628     ecgdata = connection.group_lookup(tgname)
629     ecgdata['memberUid'] = [ tuname ]
630
631     test("group_modify()")
632     connection.group_modify(tgname, ecgdata)
633     cgdata = connection.group_lookup(tgname)
634     assert_equal(ecgdata, cgdata)
635     success()
636
637     test("user_delete()")
638     connection.group_delete(tgname)
639     success()
640
641     test("disconnect()")
642     connection.disconnect()
643     success()