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