Reorganize namespace
[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 re, subprocess, ldap
13 from ceo import conf, excep, 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 ConfigurationException = conf.ConfigurationException
47 LDAPException = ldapi.LDAPException
48
49 class MemberException(Exception):
50     """Base exception class for member-related errors."""
51
52 class InvalidTerm(MemberException):
53     """Exception class for malformed terms."""
54     def __init__(self, term):
55         self.term = term
56     def __str__(self):
57         return "Term is invalid: %s" % self.term
58
59 class NoSuchMember(MemberException):
60     """Exception class for nonexistent members."""
61     def __init__(self, memberid):
62         self.memberid = memberid
63     def __str__(self):
64         return "Member not found: %d" % self.memberid
65
66 class ChildFailed(MemberException):
67     def __init__(self, program, status, output):
68         self.program, self.status, self.output = program, status, output
69     def __str__(self):
70         msg = '%s failed with status %d' % (self.program, self.status)
71         if self.output:
72             msg += ': %s' % self.output
73         return msg
74
75
76 ### Connection Management ###
77
78 # global directory connection
79 ldap_connection = ldapi.LDAPConnection()
80
81 def connect():
82     """Connect to LDAP."""
83
84     configure()
85
86     ldap_connection.connect_sasl(cfg['server_url'], cfg['sasl_mech'],
87         cfg['sasl_realm'], cfg['users_base'], cfg['groups_base'])
88
89 def disconnect():
90     """Disconnect from LDAP."""
91
92     ldap_connection.disconnect()
93
94
95 def connected():
96     """Determine whether the connection has been established."""
97
98     return ldap_connection.connected()
99
100
101
102 ### Members ###
103
104 def create_member(username, password, name, program):
105     """
106     Creates a UNIX user account with options tailored to CSC members.
107
108     Parameters:
109         username - the desired UNIX username
110         password - the desired UNIX password
111         name     - the member's real name
112         program  - the member's program of study
113
114     Exceptions:
115         InvalidArgument - on bad account attributes provided
116
117     Returns: the uid number of the new account
118
119     See: create()
120     """
121
122     # check username format
123     if not username or not re.match(cfg['username_regex'], username):
124         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
125
126     # check password length
127     if not password or len(password) < cfg['min_password_length']:
128         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
129
130     args = [ "/usr/bin/addmember", "--stdin", username, name, program ]
131     addmember = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
132     out, err = addmember.communicate(password)
133     status = addmember.wait()
134
135     if status:
136         raise ChildFailed("addmember", status, out+err)
137
138
139 def get(userid):
140     """
141     Look up attributes of a member by userid.
142
143     Returns: a dictionary of attributes
144
145     Example: get('mspang') -> {
146                  'cn': [ 'Michael Spang' ],
147                  'program': [ 'Computer Science' ],
148                  ...
149              }
150     """
151
152     return ldap_connection.user_lookup(userid)
153
154
155 def list_term(term):
156     """
157     Build a list of members in a term.
158
159     Parameters:
160         term - the term to match members against
161
162     Returns: a list of members
163
164     Example: list_term('f2006'): -> {
165                  'mspang': { 'cn': 'Michael Spang', ... },
166                  'ctdalek': { 'cn': 'Calum T. Dalek', ... },
167                  ...
168              }
169     """
170
171     return ldap_connection.member_search_term(term)
172
173
174 def list_name(name):
175     """
176     Build a list of members with matching names.
177
178     Parameters:
179         name - the name to match members against
180 Returns: a list of member dictionaries
181
182     Example: list_name('Spang'): -> {
183                  'mspang': { 'cn': 'Michael Spang', ... },
184                  ...
185              ]
186     """
187
188     return ldap_connection.member_search_name(name)
189
190
191 def list_group(group):
192     """
193     Build a list of members in a group.
194
195     Parameters:
196         group - the group to match members against
197
198     Returns: a list of member dictionaries
199
200     Example: list_name('syscom'): -> {
201                  'mspang': { 'cn': 'Michael Spang', ... },
202                  ...
203              ]
204     """
205
206     members = group_members(group)
207     ret = {}
208     if members:
209         for member in members:
210             info = get(member)
211             if info:
212                 ret[member] = info
213     return ret
214
215
216 def list_positions():
217     """
218     Build a list of positions
219
220     Returns: a list of positions and who holds them
221
222     Example: list_positions(): -> {
223                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
224                  ...
225              ]
226     """
227
228     ceo_ldap = ldap_connection.ldap
229     user_base = ldap_connection.user_base
230
231     members = ceo_ldap.search_s(user_base, ldap.SCOPE_SUBTREE, '(position=*)')
232     positions = {}
233     for (_, member) in members:
234         for position in member['position']:
235             if not position in positions:
236                 positions[position] = {}
237             positions[position][member['uid'][0]] = member
238     return positions
239
240 def set_position(position, members):
241     """
242     Sets a position
243
244     Parameters:
245         position - the position to set
246         members - an array of members that hold the position
247
248     Example: set_position('president', ['dtbartle'])
249     """
250
251     ceo_ldap = ldap_connection.ldap
252     user_base = ldap_connection.user_base
253     escape = ldap_connection.escape
254
255     res = ceo_ldap.search_s(user_base, ldap.SCOPE_SUBTREE,
256         '(&(objectClass=member)(position=%s))' % escape(position))
257     old = set([ member['uid'][0] for (_, member) in res ])
258     new = set(members)
259     mods = {
260         'del': set(old) - set(new),
261         'add': set(new) - set(old),
262     }
263     if len(mods['del']) == 0 and len(mods['add']) == 0:
264         return
265
266     for action in ['del', 'add']:
267         for userid in mods[action]:
268             dn = 'uid=%s,%s' % (escape(userid), user_base)
269             entry1 = {'position' : [position]}
270             entry2 = {} #{'position' : []}
271             entry = ()
272             if action == 'del':
273                 entry = (entry1, entry2)
274             elif action == 'add':
275                 entry = (entry2, entry1)
276             mlist = ldap_connection.make_modlist(entry[0], entry[1])
277             ceo_ldap.modify_s(dn, mlist)
278
279
280 def change_group_member(action, group, userid):
281
282     ceo_ldap = ldap_connection.ldap
283     user_base = ldap_connection.user_base
284     group_base = ldap_connection.group_base
285     escape = ldap_connection.escape
286
287     user_dn = 'uid=%s,%s' % (escape(userid), user_base)
288     group_dn = 'cn=%s,%s' % (escape(group), group_base)
289     entry1 = {'uniqueMember' : []}
290     entry2 = {'uniqueMember' : [user_dn]}
291     entry = []
292     if action == 'add' or action == 'insert':
293         entry = (entry1, entry2)
294     elif action == 'remove' or action == 'delete':
295         entry = (entry2, entry1)
296     else:
297         raise InvalidArgument("action", action, "invalid action")
298     mlist = ldap_connection.make_modlist(entry[0], entry[1])
299     ceo_ldap.modify_s(group_dn, mlist)
300
301
302
303 ### Clubs ###
304
305 def create_club(username, name):
306     """
307     Creates a UNIX user account with options tailored to CSC-hosted clubs.
308     
309     Parameters:
310         username - the desired UNIX username
311         name     - the club name
312
313     Exceptions:
314         InvalidArgument - on bad account attributes provided
315
316     Returns: the uid number of the new account
317
318     See: create()
319     """
320
321     # check username format
322     if not username or not re.match(cfg['username_regex'], username):
323         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
324     
325     args = [ "/usr/bin/addclub", username, name ]
326     addclub = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
327     out, err = addclub.communicate()
328     status = addclub.wait()
329
330     if status:
331         raise ChildFailed("addclub", status, out+err)
332
333
334
335 ### Terms ###
336
337 def register(userid, term_list):
338     """
339     Registers a member for one or more terms.
340
341     Parameters:
342         userid  - the member's username
343         term_list - the term to register for, or a list of terms
344
345     Exceptions:
346         InvalidTerm - if a term is malformed
347
348     Example: register(3349, "w2007")
349
350     Example: register(3349, ["w2007", "s2007"])
351     """
352
353     ceo_ldap = ldap_connection.ldap
354     user_base = ldap_connection.user_base
355     escape = ldap_connection.escape
356     user_dn = 'uid=%s,%s' % (escape(userid), user_base)
357
358     if type(term_list) in (str, unicode):
359         term_list = [ term_list ]
360
361     ldap_member = ldap_connection.member_lookup(userid)
362     if ldap_member and 'term' not in ldap_member:
363         ldap_member['term'] = []
364
365     if not ldap_member:
366         raise NoSuchMember(userid)
367
368     new_member = ldap_member.copy()
369     new_member['term'] = new_member['term'][:]
370
371     for term in term_list:
372
373         # check term syntax
374         if not re.match('^[wsf][0-9]{4}$', term):
375             raise InvalidTerm(term)
376
377         # add the term to the entry
378         if not term in ldap_member['term']:
379             new_member['term'].append(term)
380
381     mlist = ldap_connection.make_modlist(ldap_member, new_member)
382     ceo_ldap.modify_s(user_dn, mlist)
383
384
385 def registered(userid, term):
386     """
387     Determines whether a member is registered
388     for a term.
389
390     Parameters:
391         userid   - the member's username
392         term     - the term to check
393
394     Returns: whether the member is registered
395
396     Example: registered("mspang", "f2006") -> True
397     """
398
399     member = ldap_connection.member_lookup(userid)
400     return 'term' in member and term in member['term']
401
402
403 def group_members(group):
404
405     """
406     Returns a list of group members
407     """
408
409     group = ldap_connection.group_lookup(group)
410     if group:
411         if 'uniqueMember' in group:
412             r = re.compile('^uid=([^,]*)')
413             return map(lambda x: r.match(x).group(1), group['uniqueMember'])
414         elif 'memberUid' in group:
415             return group['memberUid']
416         else:
417             return []
418     else:
419         return []