13a0a5864ff709573371c6274ab830dc8791ada0
[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
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' ]
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):
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():
87     """Connect to LDAP."""
88
89     configure()
90
91     global ld
92     ld = ldapi.connect_sasl(cfg['server_url'],
93             cfg['sasl_mech'], cfg['sasl_realm'])
94
95
96 def disconnect():
97     """Disconnect from LDAP."""
98
99     global ld
100     ld.unbind_s()
101     ld = None
102
103
104 def connected():
105     """Determine whether the connection has been established."""
106
107     return ld and ld.connected()
108
109
110
111 ### Members ###
112
113 def create_member(username, password, name, program):
114     """
115     Creates a UNIX user account with options tailored to CSC members.
116
117     Parameters:
118         username - the desired UNIX username
119         password - the desired UNIX password
120         name     - the member's real name
121         program  - the member's program of study
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         args = [ "/usr/bin/addmember", "--stdin", username, name, program ]
141         addmember = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
142         out, err = addmember.communicate(password)
143         status = addmember.wait()
144     except OSError, e:
145         raise MemberException(e)
146
147     if status:
148         raise ChildFailed("addmember", status, out+err)
149
150
151 def get(userid):
152     """
153     Look up attributes of a member by userid.
154
155     Returns: a dictionary of attributes
156
157     Example: get('mspang') -> {
158                  'cn': [ 'Michael Spang' ],
159                  'program': [ 'Computer Science' ],
160                  ...
161              }
162     """
163
164     return ldapi.lookup(ld, 'uid', userid, cfg['users_base'])
165
166
167 def list_term(term):
168     """
169     Build a list of members in a term.
170
171     Parameters:
172         term - the term to match members against
173
174     Returns: a list of members
175
176     Example: list_term('f2006'): -> {
177                  'mspang': { 'cn': 'Michael Spang', ... },
178                  'ctdalek': { 'cn': 'Calum T. Dalek', ... },
179                  ...
180              }
181     """
182
183     members = ldapi.search(ld, cfg['users_base'],
184             '(&(objectClass=member)(term=%s))', [ term ])
185
186     return dict([(member[1]['uid'][0], member[1]) for member in members])
187
188
189 def list_name(name):
190     """
191     Build a list of members with matching names.
192
193     Parameters:
194         name - the name to match members against
195
196     Returns: a list of member dictionaries
197
198     Example: list_name('Spang'): -> {
199                  'mspang': { 'cn': 'Michael Spang', ... },
200                  ...
201              ]
202     """
203
204     members = ldapi.search(ld, cfg['users_base'],
205             '(&(objectClass=member)(cn~=%s))', [ name ])
206
207     return dict([(member[1]['uid'][0], member[1]) for member in members])
208
209
210 def list_group(group):
211     """
212     Build a list of members in a group.
213
214     Parameters:
215         group - the group to match members against
216
217     Returns: a list of member dictionaries
218
219     Example: list_name('syscom'): -> {
220                  'mspang': { 'cn': 'Michael Spang', ... },
221                  ...
222              ]
223     """
224
225     members = group_members(group)
226     ret = {}
227     if members:
228         for member in members:
229             info = get(member)
230             if info:
231                 ret[member] = info
232     return ret
233
234
235 def list_positions():
236     """
237     Build a list of positions
238
239     Returns: a list of positions and who holds them
240
241     Example: list_positions(): -> {
242                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
243                  ...
244              ]
245     """
246
247     members = ld.search_s(cfg['users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
248     positions = {}
249     for (_, member) in members:
250         for position in member['position']:
251             if not position in positions:
252                 positions[position] = {}
253             positions[position][member['uid'][0]] = member
254     return positions
255
256
257 def set_position(position, members):
258     """
259     Sets a position
260
261     Parameters:
262         position - the position to set
263         members - an array of members that hold the position
264
265     Example: set_position('president', ['dtbartle'])
266     """
267
268     res = ld.search_s(cfg['users_base'], ldap.SCOPE_SUBTREE,
269         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
270     old = set([ member['uid'][0] for (_, member) in res ])
271     new = set(members)
272     mods = {
273         'del': set(old) - set(new),
274         'add': set(new) - set(old),
275     }
276     if len(mods['del']) == 0 and len(mods['add']) == 0:
277         return
278
279     for action in ['del', 'add']:
280         for userid in mods[action]:
281             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
282             entry1 = {'position' : [position]}
283             entry2 = {} #{'position' : []}
284             entry = ()
285             if action == 'del':
286                 entry = (entry1, entry2)
287             elif action == 'add':
288                 entry = (entry2, entry1)
289             mlist = ldapi.make_modlist(entry[0], entry[1])
290             ld.modify_s(dn, mlist)
291
292
293 def change_group_member(action, group, userid):
294     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
295     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['groups_base'])
296     entry1 = {'uniqueMember' : []}
297     entry2 = {'uniqueMember' : [user_dn]}
298     entry = []
299     if action == 'add' or action == 'insert':
300         entry = (entry1, entry2)
301     elif action == 'remove' or action == 'delete':
302         entry = (entry2, entry1)
303     else:
304         raise InvalidArgument("action", action, "invalid action")
305     mlist = ldapi.make_modlist(entry[0], entry[1])
306     ld.modify_s(group_dn, mlist)
307
308
309
310 ### Shells ###
311
312 def get_shell(userid):
313     member = ldapi.lookup(ld, 'uid', userid, cfg['users_base'])
314     if not member:
315         raise NoSuchMember(userid)
316     if 'loginShell' not in member:
317         return
318     return member['loginShell'][0]
319
320
321 def get_shells():
322     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
323                 if sh
324                 and sh[0] == '/'
325                 and not '#' in sh
326                 and os.access(sh, os.X_OK) ]
327
328
329 def set_shell(userid, shell):
330     if not shell in get_shells():
331         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
332     ldapi.modify(ld, 'uid', userid, cfg['users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
333
334
335
336 ### Clubs ###
337
338 def create_club(username, name):
339     """
340     Creates a UNIX user account with options tailored to CSC-hosted clubs.
341     
342     Parameters:
343         username - the desired UNIX username
344         name     - the club name
345
346     Exceptions:
347         InvalidArgument - on bad account attributes provided
348
349     Returns: the uid number of the new account
350
351     See: create()
352     """
353
354     # check username format
355     if not username or not re.match(cfg['username_regex'], username):
356         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
357     
358     try:
359         args = [ "/usr/bin/addclub", username, name ]
360         addclub = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
361         out, err = addclub.communicate()
362         status = addclub.wait()
363     except OSError, e:
364         raise MemberException(e)
365
366     if status:
367         raise ChildFailed("addclub", status, out+err)
368
369
370
371 ### Terms ###
372
373 def register(userid, term_list):
374     """
375     Registers a member for one or more terms.
376
377     Parameters:
378         userid  - the member's username
379         term_list - the term to register for, or a list of terms
380
381     Exceptions:
382         InvalidTerm - if a term is malformed
383
384     Example: register(3349, "w2007")
385
386     Example: register(3349, ["w2007", "s2007"])
387     """
388
389     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
390
391     if type(term_list) in (str, unicode):
392         term_list = [ term_list ]
393
394     ldap_member = get(userid)
395     if ldap_member and 'term' not in ldap_member:
396         ldap_member['term'] = []
397
398     if not ldap_member:
399         raise NoSuchMember(userid)
400
401     new_member = ldap_member.copy()
402     new_member['term'] = new_member['term'][:]
403
404     for term in term_list:
405
406         # check term syntax
407         if not re.match('^[wsf][0-9]{4}$', term):
408             raise InvalidTerm(term)
409
410         # add the term to the entry
411         if not term in ldap_member['term']:
412             new_member['term'].append(term)
413
414     mlist = ldapi.make_modlist(ldap_member, new_member)
415     ld.modify_s(user_dn, mlist)
416
417
418 def registered(userid, term):
419     """
420     Determines whether a member is registered
421     for a term.
422
423     Parameters:
424         userid   - the member's username
425         term     - the term to check
426
427     Returns: whether the member is registered
428
429     Example: registered("mspang", "f2006") -> True
430     """
431
432     member = get(userid)
433     return 'term' in member and term in member['term']
434
435
436 def group_members(group):
437
438     """
439     Returns a list of group members
440     """
441
442     group = ldapi.lookup(ld, 'cn', group, cfg['groups_base'])
443
444     if group:
445         if 'uniqueMember' in group:
446             r = re.compile('^uid=([^,]*)')
447             return map(lambda x: r.match(x).group(1), group['uniqueMember'])
448         elif 'memberUid' in group:
449             return group['memberUid']
450         else:
451             return []
452     else:
453         return []