Merge commit 'ceod'
[public/pyceo-broken.git] / ceo / members.py
1 """
2 CSC Member Management
3
4 This module contains functions for registering new members, registering
5 members for terms, searching for members, and other member-related
6 functions.
7
8 Transactions are used in each method that modifies the database. 
9 Future changes to the members database that need to be atomic
10 must also be moved into this module.
11 """
12 import os, re, subprocess, ldap
13 from ceo import conf, ldapi, terms
14 from ceo.excep import InvalidArgument
15
16
17 ### Configuration ###
18
19 CONFIG_FILE = '/etc/csc/accounts.cf'
20
21 cfg = {}
22
23 def configure():
24     """Load Members Configuration"""
25
26     string_fields = [ 'username_regex', 'shells_file', 'ldap_server_url',
27             'ldap_users_base', 'ldap_groups_base', 'ldap_sasl_mech', 'ldap_sasl_realm',
28             'expire_hook', 'mathsoc_regex', 'mathsoc_dont_count' ]
29     numeric_fields = [ 'min_password_length' ]
30
31     # read configuration file
32     cfg_tmp = conf.read(CONFIG_FILE)
33
34     # verify configuration
35     conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
36     conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
37
38     # update the current configuration with the loaded values
39     cfg.update(cfg_tmp)
40
41
42
43 ### Exceptions ###
44
45 class MemberException(Exception):
46     """Base exception class for member-related errors."""
47     def __init__(self, ex=None):
48         Exception.__init__(self)
49         self.ex = ex
50     def __str__(self):
51         return str(self.ex)
52
53 class InvalidTerm(MemberException):
54     """Exception class for malformed terms."""
55     def __init__(self, term):
56         MemberException.__init__(self)
57         self.term = term
58     def __str__(self):
59         return "Term is invalid: %s" % self.term
60
61 class NoSuchMember(MemberException):
62     """Exception class for nonexistent members."""
63     def __init__(self, memberid):
64         MemberException.__init__(self)
65         self.memberid = memberid
66     def __str__(self):
67         return "Member not found: %d" % self.memberid
68
69 class ChildFailed(MemberException):
70     def __init__(self, program, status, output):
71         MemberException.__init__(self)
72         self.program, self.status, self.output = program, status, output
73     def __str__(self):
74         msg = '%s failed with status %d' % (self.program, self.status)
75         if self.output:
76             msg += ': %s' % self.output
77         return msg
78
79
80 ### Connection Management ###
81
82 # global directory connection
83 ld = None
84
85 def connect(auth_callback):
86     """Connect to LDAP."""
87
88
89     global ld
90     password = None
91     tries = 0
92     while ld is None:
93         try:
94             ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'],
95                 cfg['ldap_sasl_realm'], password)
96         except ldap.LOCAL_ERROR, e:
97             tries += 1
98             if tries > 3:
99                 raise e
100             password = auth_callback.callback(e)
101             if password == None:
102                 raise e
103
104
105 def disconnect():
106     """Disconnect from LDAP."""
107
108     global ld
109     ld.unbind_s()
110     ld = None
111
112
113 def connected():
114     """Determine whether the connection has been established."""
115
116     return ld and ld.connected()
117
118
119
120 ### Members ###
121
122 def create_member(username, password, name, program):
123     """
124     Creates a UNIX user account with options tailored to CSC members.
125
126     Parameters:
127         username - the desired UNIX username
128         password - the desired UNIX password
129         name     - the member's real name
130         program  - the member's program of study
131
132     Exceptions:
133         InvalidArgument - on bad account attributes provided
134
135     Returns: the uid number of the new account
136
137     See: create()
138     """
139
140     # check username format
141     if not username or not re.match(cfg['username_regex'], username):
142         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
143
144     # check password length
145     if not password or len(password) < cfg['min_password_length']:
146         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
147
148     try:
149         args = [ "/usr/bin/addmember", "--stdin", username, name, program ]
150         addmember = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
151         out, err = addmember.communicate(password)
152         status = addmember.wait()
153
154         # # If the user was created, consider adding them to the mailing list
155         # if not status:
156         #     listadmin_cfg_file = "/path/to/the/listadmin/config/file"
157         #     mail = subprocess.Popen(["/usr/bin/listadmin", "-f", listadmin_cfg_file, "--add-member", username + "@csclub.uwaterloo.ca"])
158         #     status2 = mail.wait() # Fuck if I care about errors!
159     except OSError, e:
160         raise MemberException(e)
161
162     if status:
163         raise ChildFailed("addmember", status, out+err)
164
165
166 def get(userid):
167     """
168     Look up attributes of a member by userid.
169
170     Returns: a dictionary of attributes
171
172     Example: get('mspang') -> {
173                  'cn': [ 'Michael Spang' ],
174                  'program': [ 'Computer Science' ],
175                  ...
176              }
177     """
178
179     return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
180
181 def uid2dn(uid):
182     return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
183
184
185 def list_term(term):
186     """
187     Build a list of members in a term.
188
189     Parameters:
190         term - the term to match members against
191
192     Returns: a list of members
193
194     Example: list_term('f2006'): -> {
195                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
196                  'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
197                  ...
198              }
199     """
200
201     members = ldapi.search(ld, cfg['ldap_users_base'],
202             '(&(objectClass=member)(term=%s))', [ term ])
203     return dict([(member[0], member[1]) for member in members])
204
205
206 def list_name(name):
207     """
208     Build a list of members with matching names.
209
210     Parameters:
211         name - the name to match members against
212
213     Returns: a list of member dictionaries
214
215     Example: list_name('Spang'): -> {
216                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
217                  ...
218              ]
219     """
220
221     members = ldapi.search(ld, cfg['ldap_users_base'],
222             '(&(objectClass=member)(cn~=%s))', [ name ])
223     return dict([(member[0], member[1]) for member in members])
224
225
226 def list_group(group):
227     """
228     Build a list of members in a group.
229
230     Parameters:
231         group - the group to match members against
232
233     Returns: a list of member dictionaries
234
235     Example: list_name('syscom'): -> {
236                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
237                  ...
238              ]
239     """
240
241     members = group_members(group)
242     ret = {}
243     if members:
244         for member in members:
245             info = get(member)
246             if info:
247                 ret[uid2dn(member)] = info
248     return ret
249
250
251 def list_all():
252     """
253     Build a list of all members
254
255     Returns: a list of member dictionaries
256
257     Example: list_name('Spang'): -> {
258                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
259                  ...
260              ]
261     """
262
263     members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
264     return dict([(member[0], member[1]) for member in members])
265
266
267 def list_positions():
268     """
269     Build a list of positions
270
271     Returns: a list of positions and who holds them
272
273     Example: list_positions(): -> {
274                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
275                  ...
276              ]
277     """
278
279     members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
280     positions = {}
281     for (_, member) in members:
282         for position in member['position']:
283             if not position in positions:
284                 positions[position] = {}
285             positions[position][member['uid'][0]] = member
286     return positions
287
288
289 def set_position(position, members):
290     """
291     Sets a position
292
293     Parameters:
294         position - the position to set
295         members - an array of members that hold the position
296
297     Example: set_position('president', ['dtbartle'])
298     """
299
300     res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
301         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
302     old = set([ member['uid'][0] for (_, member) in res ])
303     new = set(members)
304     mods = {
305         'del': set(old) - set(new),
306         'add': set(new) - set(old),
307     }
308     if len(mods['del']) == 0 and len(mods['add']) == 0:
309         return
310
311     for action in ['del', 'add']:
312         for userid in mods[action]:
313             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
314             entry1 = {'position' : [position]}
315             entry2 = {} #{'position' : []}
316             entry = ()
317             if action == 'del':
318                 entry = (entry1, entry2)
319             elif action == 'add':
320                 entry = (entry2, entry1)
321             mlist = ldapi.make_modlist(entry[0], entry[1])
322             ld.modify_s(dn, mlist)
323
324
325 def change_group_member(action, group, userid):
326     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
327     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
328     entry1 = {'uniqueMember' : []}
329     entry2 = {'uniqueMember' : [user_dn]}
330     entry = []
331     if action == 'add' or action == 'insert':
332         entry = (entry1, entry2)
333     elif action == 'remove' or action == 'delete':
334         entry = (entry2, entry1)
335     else:
336         raise InvalidArgument("action", action, "invalid action")
337     mlist = ldapi.make_modlist(entry[0], entry[1])
338     ld.modify_s(group_dn, mlist)
339
340
341
342 ### Shells ###
343
344 def get_shell(userid):
345     member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
346     if not member:
347         raise NoSuchMember(userid)
348     if 'loginShell' not in member:
349         return
350     return member['loginShell'][0]
351
352
353 def get_shells():
354     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
355                 if sh
356                 and sh[0] == '/'
357                 and not '#' in sh
358                 and os.access(sh, os.X_OK) ]
359
360
361 def set_shell(userid, shell):
362     if not shell in get_shells():
363         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
364     ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
365
366
367
368 ### Clubs ###
369
370 def create_club(username, name):
371     """
372     Creates a UNIX user account with options tailored to CSC-hosted clubs.
373     
374     Parameters:
375         username - the desired UNIX username
376         name     - the club name
377
378     Exceptions:
379         InvalidArgument - on bad account attributes provided
380
381     Returns: the uid number of the new account
382
383     See: create()
384     """
385
386     # check username format
387     if not username or not re.match(cfg['username_regex'], username):
388         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
389     
390     try:
391         args = [ "/usr/bin/addclub", username, name ]
392         addclub = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
393         out, err = addclub.communicate()
394         status = addclub.wait()
395     except OSError, e:
396         raise MemberException(e)
397
398     if status:
399         raise ChildFailed("addclub", status, out+err)
400
401
402
403 ### Terms ###
404
405 def register(userid, term_list):
406     """
407     Registers a member for one or more terms.
408
409     Parameters:
410         userid  - the member's username
411         term_list - the term to register for, or a list of terms
412
413     Exceptions:
414         InvalidTerm - if a term is malformed
415
416     Example: register(3349, "w2007")
417
418     Example: register(3349, ["w2007", "s2007"])
419     """
420
421     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
422
423     if type(term_list) in (str, unicode):
424         term_list = [ term_list ]
425
426     ldap_member = get(userid)
427     if ldap_member and 'term' not in ldap_member:
428         ldap_member['term'] = []
429
430     if not ldap_member:
431         raise NoSuchMember(userid)
432
433     new_member = ldap_member.copy()
434     new_member['term'] = new_member['term'][:]
435
436     for term in term_list:
437
438         # check term syntax
439         if not re.match('^[wsf][0-9]{4}$', term):
440             raise InvalidTerm(term)
441
442         # add the term to the entry
443         if not term in ldap_member['term']:
444             new_member['term'].append(term)
445
446     mlist = ldapi.make_modlist(ldap_member, new_member)
447     ld.modify_s(user_dn, mlist)
448
449
450 def register_nonmember(userid, term_list):
451     """Registers a non-member for one or more terms."""
452
453     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
454
455     if type(term_list) in (str, unicode):
456         term_list = [ term_list ]
457
458     ldap_member = get(userid)
459     if not ldap_member:
460         raise NoSuchMember(userid)
461
462     if 'term' not in ldap_member:
463         ldap_member['term'] = []
464     if 'nonMemberTerm' not in ldap_member:
465         ldap_member['nonMemberTerm'] = []
466
467     new_member = ldap_member.copy()
468     new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
469
470     for term in term_list:
471
472         # check term syntax
473         if not re.match('^[wsf][0-9]{4}$', term):
474             raise InvalidTerm(term)
475
476         # add the term to the entry
477         if not term in ldap_member['nonMemberTerm'] \
478                 and not term in ldap_member['term']:
479             new_member['nonMemberTerm'].append(term)
480
481     mlist = ldapi.make_modlist(ldap_member, new_member)
482     ld.modify_s(user_dn, mlist)
483
484
485 def registered(userid, term):
486     """
487     Determines whether a member is registered
488     for a term.
489
490     Parameters:
491         userid   - the member's username
492         term     - the term to check
493
494     Returns: whether the member is registered
495
496     Example: registered("mspang", "f2006") -> True
497     """
498
499     member = get(userid)
500     if not member is None:
501         return 'term' in member and term in member['term']
502     else:
503         return False
504
505
506 def group_members(group):
507
508     """
509     Returns a list of group members
510     """
511
512     group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
513
514     if group and 'uniqueMember' in group:
515         r = re.compile('^uid=([^,]*)')
516         return map(lambda x: r.match(x).group(1), group['uniqueMember'])
517     return []
518
519 def expired_accounts():
520     members = ldapi.search(ld, cfg['ldap_users_base'],
521         '(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
522         (terms.current(), terms.current()))
523     return dict([(member[0], member[1]) for member in members])
524
525 def send_account_expired_email(name, email):
526     args = [ cfg['expire_hook'], name, email ]
527     os.spawnv(os.P_WAIT, cfg['expire_hook'], args)