Add UI for email forwarding
[mspang/pyceo.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, socket
13 from ceo import conf, ldapi, terms, remote, ceo_pb2
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
70 ### Connection Management ###
71
72 # global directory connection
73 ld = None
74
75 def connect(auth_callback):
76     """Connect to LDAP."""
77
78
79     global ld
80     password = None
81     tries = 0
82     while ld is None:
83         try:
84             ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'],
85                 cfg['ldap_sasl_realm'], password)
86         except ldap.LOCAL_ERROR, e:
87             tries += 1
88             if tries > 3:
89                 raise e
90             password = auth_callback.callback(e)
91             if password == None:
92                 raise e
93
94
95 def disconnect():
96     """Disconnect from LDAP."""
97
98     global ld
99     ld.unbind_s()
100     ld = None
101
102
103 def connected():
104     """Determine whether the connection has been established."""
105
106     return ld and ld.connected()
107
108
109
110 ### Members ###
111
112 def create_member(username, password, name, program, email):
113     """
114     Creates a UNIX user account with options tailored to CSC members.
115
116     Parameters:
117         username - the desired UNIX username
118         password - the desired UNIX password
119         name     - the member's real name
120         program  - the member's program of study
121         email    - email to place in .forward
122
123     Exceptions:
124         InvalidArgument - on bad account attributes provided
125
126     Returns: the uid number of the new account
127
128     See: create()
129     """
130
131     # check username format
132     if not username or not re.match(cfg['username_regex'], username):
133         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
134
135     # check password length
136     if not password or len(password) < cfg['min_password_length']:
137         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
138
139     try:
140         request = ceo_pb2.AddUser()
141         request.type = ceo_pb2.AddUser.MEMBER
142         request.username = username
143         request.password = password
144         request.realname = name
145         request.program = program
146         request.email = email
147
148         out = remote.run_remote('adduser', request.SerializeToString())
149
150         response = ceo_pb2.AddUserResponse()
151         response.ParseFromString(out)
152
153         if any(message.status != 0 for message in response.messages):
154             raise MemberException('\n'.join(message.message for message in response.messages))
155
156         # # If the user was created, consider adding them to the mailing list
157         # if not status:
158         #     listadmin_cfg_file = "/path/to/the/listadmin/config/file"
159         #     mail = subprocess.Popen(["/usr/bin/listadmin", "-f", listadmin_cfg_file, "--add-member", username + "@csclub.uwaterloo.ca"])
160         #     status2 = mail.wait() # Fuck if I care about errors!
161     except remote.RemoteException, e:
162         raise MemberException(e)
163     except OSError, e:
164         raise MemberException(e)
165
166
167 def check_email(email):
168     match = re.match('^\S+?@(\S+)$', email)
169     if not match:
170         return 'Invalid email address'
171
172     # some characters are treated specially in .forward
173     for c in email:
174         if c in ('"', "'", ',', '|', '$', '/', '#', ':'):
175             return 'Invalid character in address: %s' % c
176
177     host = match.group(1)
178     try:
179         ip = socket.gethostbyname(host)
180     except:
181         return 'Invalid host: %s' % host
182
183
184 def current_email(username):
185     fwdpath = '%s/%s/.forward' % (cfg['member_home'], username)
186     try:
187         fwd = open(fwdpath).read().strip()
188         if not check_email(fwd):
189             return fwd
190     except OSError:
191         pass
192     except IOError:
193         pass
194
195
196 def change_email(username, forward):
197     try:
198         request = ceo_pb2.UpdateMail()
199         request.username = username
200         request.forward = forward
201
202         out = remote.run_remote('mail', request.SerializeToString())
203
204         response = ceo_pb2.AddUserResponse()
205         response.ParseFromString(out)
206
207         if any(message.status != 0 for message in response.messages):
208             return '\n'.join(message.message for message in response.messages)
209     except remote.RemoteException, e:
210         raise MemberException(e)
211     except OSError, e:
212         raise MemberException(e)
213
214
215 def get(userid):
216     """
217     Look up attributes of a member by userid.
218
219     Returns: a dictionary of attributes
220
221     Example: get('mspang') -> {
222                  'cn': [ 'Michael Spang' ],
223                  'program': [ 'Computer Science' ],
224                  ...
225              }
226     """
227
228     return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
229
230 def uid2dn(uid):
231     return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
232
233
234 def list_term(term):
235     """
236     Build a list of members in a term.
237
238     Parameters:
239         term - the term to match members against
240
241     Returns: a list of members
242
243     Example: list_term('f2006'): -> {
244                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
245                  'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
246                  ...
247              }
248     """
249
250     members = ldapi.search(ld, cfg['ldap_users_base'],
251             '(&(objectClass=member)(term=%s))', [ term ])
252     return dict([(member[0], member[1]) for member in members])
253
254
255 def list_name(name):
256     """
257     Build a list of members with matching names.
258
259     Parameters:
260         name - the name to match members against
261
262     Returns: a list of member dictionaries
263
264     Example: list_name('Spang'): -> {
265                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
266                  ...
267              ]
268     """
269
270     members = ldapi.search(ld, cfg['ldap_users_base'],
271             '(&(objectClass=member)(cn~=%s))', [ name ])
272     return dict([(member[0], member[1]) for member in members])
273
274
275 def list_group(group):
276     """
277     Build a list of members in a group.
278
279     Parameters:
280         group - the group to match members against
281
282     Returns: a list of member dictionaries
283
284     Example: list_name('syscom'): -> {
285                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
286                  ...
287              ]
288     """
289
290     members = group_members(group)
291     ret = {}
292     if members:
293         for member in members:
294             info = get(member)
295             if info:
296                 ret[uid2dn(member)] = info
297     return ret
298
299
300 def list_all():
301     """
302     Build a list of all members
303
304     Returns: a list of member dictionaries
305
306     Example: list_name('Spang'): -> {
307                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
308                  ...
309              ]
310     """
311
312     members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
313     return dict([(member[0], member[1]) for member in members])
314
315
316 def list_positions():
317     """
318     Build a list of positions
319
320     Returns: a list of positions and who holds them
321
322     Example: list_positions(): -> {
323                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
324                  ...
325              ]
326     """
327
328     members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
329     positions = {}
330     for (_, member) in members:
331         for position in member['position']:
332             if not position in positions:
333                 positions[position] = {}
334             positions[position][member['uid'][0]] = member
335     return positions
336
337
338 def set_position(position, members):
339     """
340     Sets a position
341
342     Parameters:
343         position - the position to set
344         members - an array of members that hold the position
345
346     Example: set_position('president', ['dtbartle'])
347     """
348
349     res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
350         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
351     old = set([ member['uid'][0] for (_, member) in res ])
352     new = set(members)
353     mods = {
354         'del': set(old) - set(new),
355         'add': set(new) - set(old),
356     }
357     if len(mods['del']) == 0 and len(mods['add']) == 0:
358         return
359
360     for action in ['del', 'add']:
361         for userid in mods[action]:
362             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
363             entry1 = {'position' : [position]}
364             entry2 = {} #{'position' : []}
365             entry = ()
366             if action == 'del':
367                 entry = (entry1, entry2)
368             elif action == 'add':
369                 entry = (entry2, entry1)
370             mlist = ldapi.make_modlist(entry[0], entry[1])
371             ld.modify_s(dn, mlist)
372
373
374 def change_group_member(action, group, userid):
375     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
376     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
377     entry1 = {'uniqueMember' : []}
378     entry2 = {'uniqueMember' : [user_dn]}
379     entry = []
380     if action == 'add' or action == 'insert':
381         entry = (entry1, entry2)
382     elif action == 'remove' or action == 'delete':
383         entry = (entry2, entry1)
384     else:
385         raise InvalidArgument("action", action, "invalid action")
386     mlist = ldapi.make_modlist(entry[0], entry[1])
387     ld.modify_s(group_dn, mlist)
388
389
390
391 ### Shells ###
392
393 def get_shell(userid):
394     member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
395     if not member:
396         raise NoSuchMember(userid)
397     if 'loginShell' not in member:
398         return
399     return member['loginShell'][0]
400
401
402 def get_shells():
403     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
404                 if sh
405                 and sh[0] == '/'
406                 and not '#' in sh
407                 and os.access(sh, os.X_OK) ]
408
409
410 def set_shell(userid, shell):
411     if not shell in get_shells():
412         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
413     ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
414
415
416
417 ### Clubs ###
418
419 def create_club(username, name):
420     """
421     Creates a UNIX user account with options tailored to CSC-hosted clubs.
422     
423     Parameters:
424         username - the desired UNIX username
425         name     - the club name
426
427     Exceptions:
428         InvalidArgument - on bad account attributes provided
429
430     Returns: the uid number of the new account
431
432     See: create()
433     """
434
435     # check username format
436     if not username or not re.match(cfg['username_regex'], username):
437         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
438     
439     try:
440         request = ceo_pb2.AddUser()
441         request.type = ceo_pb2.AddUser.CLUB
442         request.username = username
443         request.realname = name
444
445         out = remote.run_remote('adduser', request.SerializeToString())
446
447         response = ceo_pb2.AddUserResponse()
448         response.ParseFromString(out)
449
450         if any(message.status != 0 for message in response.messages):
451             raise MemberException('\n'.join(message.message for message in response.messages))
452     except remote.RemoteException, e:
453         raise MemberException(e)
454     except OSError, e:
455         raise MemberException(e)
456
457
458
459 ### Terms ###
460
461 def register(userid, term_list):
462     """
463     Registers a member for one or more terms.
464
465     Parameters:
466         userid  - the member's username
467         term_list - the term to register for, or a list of terms
468
469     Exceptions:
470         InvalidTerm - if a term is malformed
471
472     Example: register(3349, "w2007")
473
474     Example: register(3349, ["w2007", "s2007"])
475     """
476
477     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
478
479     if type(term_list) in (str, unicode):
480         term_list = [ term_list ]
481
482     ldap_member = get(userid)
483     if ldap_member and 'term' not in ldap_member:
484         ldap_member['term'] = []
485
486     if not ldap_member:
487         raise NoSuchMember(userid)
488
489     new_member = ldap_member.copy()
490     new_member['term'] = new_member['term'][:]
491
492     for term in term_list:
493
494         # check term syntax
495         if not re.match('^[wsf][0-9]{4}$', term):
496             raise InvalidTerm(term)
497
498         # add the term to the entry
499         if not term in ldap_member['term']:
500             new_member['term'].append(term)
501
502     mlist = ldapi.make_modlist(ldap_member, new_member)
503     ld.modify_s(user_dn, mlist)
504
505
506 def register_nonmember(userid, term_list):
507     """Registers a non-member for one or more terms."""
508
509     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
510
511     if type(term_list) in (str, unicode):
512         term_list = [ term_list ]
513
514     ldap_member = get(userid)
515     if not ldap_member:
516         raise NoSuchMember(userid)
517
518     if 'term' not in ldap_member:
519         ldap_member['term'] = []
520     if 'nonMemberTerm' not in ldap_member:
521         ldap_member['nonMemberTerm'] = []
522
523     new_member = ldap_member.copy()
524     new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
525
526     for term in term_list:
527
528         # check term syntax
529         if not re.match('^[wsf][0-9]{4}$', term):
530             raise InvalidTerm(term)
531
532         # add the term to the entry
533         if not term in ldap_member['nonMemberTerm'] \
534                 and not term in ldap_member['term']:
535             new_member['nonMemberTerm'].append(term)
536
537     mlist = ldapi.make_modlist(ldap_member, new_member)
538     ld.modify_s(user_dn, mlist)
539
540
541 def registered(userid, term):
542     """
543     Determines whether a member is registered
544     for a term.
545
546     Parameters:
547         userid   - the member's username
548         term     - the term to check
549
550     Returns: whether the member is registered
551
552     Example: registered("mspang", "f2006") -> True
553     """
554
555     member = get(userid)
556     if not member is None:
557         return 'term' in member and term in member['term']
558     else:
559         return False
560
561
562 def group_members(group):
563
564     """
565     Returns a list of group members
566     """
567
568     group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
569
570     if group and 'uniqueMember' in group:
571         r = re.compile('^uid=([^,]*)')
572         return map(lambda x: r.match(x).group(1), group['uniqueMember'])
573     return []
574
575 def expired_accounts():
576     members = ldapi.search(ld, cfg['ldap_users_base'],
577         '(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
578         (terms.current(), terms.current()))
579     return dict([(member[0], member[1]) for member in members])
580
581 def send_account_expired_email(name, email):
582     args = [ cfg['expire_hook'], name, email ]
583     os.spawnv(os.P_WAIT, cfg['expire_hook'], args)