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