tell ceod when it is a club rep; club reps don't need the new member email
[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' ]
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, club_rep=False):
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         club_rep - whether the user is a club rep
127         email    - email to place in .forward
128
129     Exceptions:
130         InvalidArgument - on bad account attributes provided
131
132     Returns: the uid number of the new account
133
134     See: create()
135     """
136
137     # check username format
138     if not username or not re.match(cfg['username_regex'], username):
139         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
140
141     # check password length
142     if not password or len(password) < cfg['min_password_length']:
143         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
144
145     try:
146         request = ceo_pb2.AddUser()
147         request.username = username
148         request.password = password
149         request.realname = name
150         request.program = program
151         request.email = email
152
153         if club_rep:
154             request.type = ceo_pb2.AddUser.CLUB_REP
155         else:
156             request.type = ceo_pb2.AddUser.MEMBER
157
158         out = remote.run_remote('adduser', request.SerializeToString())
159
160         response = ceo_pb2.AddUserResponse()
161         response.ParseFromString(out)
162
163         if any(message.status != 0 for message in response.messages):
164             raise MemberException('\n'.join(message.message for message in response.messages))
165
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 get_group(group):
236     """
237     Look up group by groupname
238
239     Returns a dictionary of group attributes
240     """
241
242     return ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
243
244 def uid2dn(uid):
245     return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
246
247
248 def list_term(term):
249     """
250     Build a list of members in a term.
251
252     Parameters:
253         term - the term to match members against
254
255     Returns: a list of members
256
257     Example: list_term('f2006'): -> {
258                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
259                  'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
260                  ...
261              }
262     """
263
264     members = ldapi.search(ld, cfg['ldap_users_base'],
265             '(&(objectClass=member)(term=%s))', [ term ])
266     return dict([(member[0], member[1]) for member in members])
267
268
269 def list_name(name):
270     """
271     Build a list of members with matching names.
272
273     Parameters:
274         name - the name to match members against
275
276     Returns: a list of member dictionaries
277
278     Example: list_name('Spang'): -> {
279                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
280                  ...
281              ]
282     """
283
284     members = ldapi.search(ld, cfg['ldap_users_base'],
285             '(&(objectClass=member)(cn~=%s))', [ name ])
286     return dict([(member[0], member[1]) for member in members])
287
288
289 def list_group(group):
290     """
291     Build a list of members in a group.
292
293     Parameters:
294         group - the group to match members against
295
296     Returns: a list of member dictionaries
297
298     Example: list_name('syscom'): -> {
299                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
300                  ...
301              ]
302     """
303
304     members = group_members(group)
305     ret = {}
306     if members:
307         for member in members:
308             info = get(member)
309             if info:
310                 ret[uid2dn(member)] = info
311     return ret
312
313
314 def list_all():
315     """
316     Build a list of all members
317
318     Returns: a list of member dictionaries
319
320     Example: list_name('Spang'): -> {
321                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
322                  ...
323              ]
324     """
325
326     members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
327     return dict([(member[0], member[1]) for member in members])
328
329
330 def list_positions():
331     """
332     Build a list of positions
333
334     Returns: a list of positions and who holds them
335
336     Example: list_positions(): -> {
337                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
338                  ...
339              ]
340     """
341
342     members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
343     positions = {}
344     for (_, member) in members:
345         for position in member['position']:
346             if not position in positions:
347                 positions[position] = {}
348             positions[position][member['uid'][0]] = member
349     return positions
350
351
352 def set_position(position, members):
353     """
354     Sets a position
355
356     Parameters:
357         position - the position to set
358         members - an array of members that hold the position
359
360     Example: set_position('president', ['dtbartle'])
361     """
362
363     res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
364         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
365     old = set([ member['uid'][0] for (_, member) in res ])
366     new = set(members)
367     mods = {
368         'del': set(old) - set(new),
369         'add': set(new) - set(old),
370     }
371     if len(mods['del']) == 0 and len(mods['add']) == 0:
372         return
373
374     for action in ['del', 'add']:
375         for userid in mods[action]:
376             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
377             entry1 = {'position' : [position]}
378             entry2 = {} #{'position' : []}
379             entry = ()
380             if action == 'del':
381                 entry = (entry1, entry2)
382             elif action == 'add':
383                 entry = (entry2, entry1)
384             mlist = ldapi.make_modlist(entry[0], entry[1])
385             ld.modify_s(dn, mlist)
386
387
388 def change_group_member(action, group, userid):
389     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
390     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
391     entry1 = {'uniqueMember' : []}
392     entry2 = {'uniqueMember' : [user_dn]}
393     entry = []
394     if action == 'add' or action == 'insert':
395         entry = (entry1, entry2)
396     elif action == 'remove' or action == 'delete':
397         entry = (entry2, entry1)
398     else:
399         raise InvalidArgument("action", action, "invalid action")
400     mlist = ldapi.make_modlist(entry[0], entry[1])
401     ld.modify_s(group_dn, mlist)
402
403
404
405 ### Shells ###
406
407 def get_shell(userid):
408     member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
409     if not member:
410         raise NoSuchMember(userid)
411     if 'loginShell' not in member:
412         return
413     return member['loginShell'][0]
414
415
416 def get_shells():
417     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
418                 if sh
419                 and sh[0] == '/'
420                 and not '#' in sh
421                 and os.access(sh, os.X_OK) ]
422
423
424 def set_shell(userid, shell):
425     if not shell in get_shells():
426         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
427     ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
428
429
430
431 ### Clubs ###
432
433 def create_club(username, name):
434     """
435     Creates a UNIX user account with options tailored to CSC-hosted clubs.
436     
437     Parameters:
438         username - the desired UNIX username
439         name     - the club name
440
441     Exceptions:
442         InvalidArgument - on bad account attributes provided
443
444     Returns: the uid number of the new account
445
446     See: create()
447     """
448
449     # check username format
450     if not username or not re.match(cfg['username_regex'], username):
451         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
452     
453     try:
454         request = ceo_pb2.AddUser()
455         request.type = ceo_pb2.AddUser.CLUB
456         request.username = username
457         request.realname = name
458
459         out = remote.run_remote('adduser', request.SerializeToString())
460
461         response = ceo_pb2.AddUserResponse()
462         response.ParseFromString(out)
463
464         if any(message.status != 0 for message in response.messages):
465             raise MemberException('\n'.join(message.message for message in response.messages))
466     except remote.RemoteException, e:
467         raise MemberException(e)
468     except OSError, e:
469         raise MemberException(e)
470
471
472
473 ### Terms ###
474
475 def register(userid, term_list):
476     """
477     Registers a member for one or more terms.
478
479     Parameters:
480         userid  - the member's username
481         term_list - the term to register for, or a list of terms
482
483     Exceptions:
484         InvalidTerm - if a term is malformed
485
486     Example: register(3349, "w2007")
487
488     Example: register(3349, ["w2007", "s2007"])
489     """
490
491     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
492
493     if type(term_list) in (str, unicode):
494         term_list = [ term_list ]
495
496     ldap_member = get(userid)
497     if ldap_member and 'term' not in ldap_member:
498         ldap_member['term'] = []
499
500     if not ldap_member:
501         raise NoSuchMember(userid)
502
503     new_member = ldap_member.copy()
504     new_member['term'] = new_member['term'][:]
505
506     for term in term_list:
507
508         # check term syntax
509         if not re.match('^[wsf][0-9]{4}$', term):
510             raise InvalidTerm(term)
511
512         # add the term to the entry
513         if not term in ldap_member['term']:
514             new_member['term'].append(term)
515
516     mlist = ldapi.make_modlist(ldap_member, new_member)
517     ld.modify_s(user_dn, mlist)
518
519
520 def register_nonmember(userid, term_list):
521     """Registers a non-member for one or more terms."""
522
523     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
524
525     if type(term_list) in (str, unicode):
526         term_list = [ term_list ]
527
528     ldap_member = get(userid)
529     if not ldap_member:
530         raise NoSuchMember(userid)
531
532     if 'term' not in ldap_member:
533         ldap_member['term'] = []
534     if 'nonMemberTerm' not in ldap_member:
535         ldap_member['nonMemberTerm'] = []
536
537     new_member = ldap_member.copy()
538     new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
539
540     for term in term_list:
541
542         # check term syntax
543         if not re.match('^[wsf][0-9]{4}$', term):
544             raise InvalidTerm(term)
545
546         # add the term to the entry
547         if not term in ldap_member['nonMemberTerm'] \
548                 and not term in ldap_member['term']:
549             new_member['nonMemberTerm'].append(term)
550
551     mlist = ldapi.make_modlist(ldap_member, new_member)
552     ld.modify_s(user_dn, mlist)
553
554
555 def registered(userid, term):
556     """
557     Determines whether a member is registered
558     for a term.
559
560     Parameters:
561         userid   - the member's username
562         term     - the term to check
563
564     Returns: whether the member is registered
565
566     Example: registered("mspang", "f2006") -> True
567     """
568
569     member = get(userid)
570     if not member is None:
571         return 'term' in member and term in member['term']
572     else:
573         return False
574
575
576 def group_members(group):
577
578     """
579     Returns a list of group members
580     """
581
582     group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
583
584     if group and 'uniqueMember' in group:
585         r = re.compile('^uid=([^,]*)')
586         return map(lambda x: r.match(x).group(1), group['uniqueMember'])
587     return []
588
589 def expired_accounts():
590     members = ldapi.search(ld, cfg['ldap_users_base'],
591         '(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
592         (terms.current(), terms.current()))
593     return dict([(member[0], member[1]) for member in members])
594
595 def send_account_expired_email(name, email):
596     args = [ cfg['expire_hook'], name, email ]
597     os.spawnv(os.P_WAIT, cfg['expire_hook'], args)
598
599 def subscribe_to_mailing_list(name):
600     member = get(name)
601     if member is not None:
602         return remote.run_remote('mailman', name)
603     else:
604         return 'Error: member does not exist'