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