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