""" CSC Member Management This module contains functions for registering new members, registering members for terms, searching for members, and other member-related functions. Transactions are used in each method that modifies the database. Future changes to the members database that need to be atomic must also be moved into this module. """ import os, re, subprocess, ldap, socket from ceo import conf, ldapi, terms, remote, ceo_pb2 from ceo.excep import InvalidArgument import dns.resolver ### Configuration ### CONFIG_FILE = '/etc/csc/accounts.cf' cfg = {} def configure(): """Load Members Configuration""" string_fields = [ 'username_regex', 'shells_file', 'ldap_server_url', 'ldap_users_base', 'ldap_groups_base', 'ldap_sasl_mech', 'ldap_sasl_realm', 'expire_hook' ] numeric_fields = [ 'min_password_length' ] # read configuration file cfg_tmp = conf.read(CONFIG_FILE) # verify configuration conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp) conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp) # update the current configuration with the loaded values cfg.update(cfg_tmp) ### Exceptions ### class MemberException(Exception): """Base exception class for member-related errors.""" def __init__(self, ex=None): Exception.__init__(self) self.ex = ex def __str__(self): return str(self.ex) class InvalidTerm(MemberException): """Exception class for malformed terms.""" def __init__(self, term): MemberException.__init__(self) self.term = term def __str__(self): return "Term is invalid: %s" % self.term class NoSuchMember(MemberException): """Exception class for nonexistent members.""" def __init__(self, memberid): MemberException.__init__(self) self.memberid = memberid def __str__(self): return "Member not found: %d" % self.memberid ### Connection Management ### # global directory connection ld = None def connect(auth_callback): """Connect to LDAP.""" global ld password = None tries = 0 while ld is None: try: ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'], cfg['ldap_sasl_realm'], password) except ldap.LOCAL_ERROR, e: tries += 1 if tries > 3: raise e password = auth_callback.callback(e) if password == None: raise e def connect_anonymous(): """Connect to LDAP.""" global ld ld = ldap.initialize(cfg['ldap_server_url']) def disconnect(): """Disconnect from LDAP.""" global ld ld.unbind_s() ld = None def connected(): """Determine whether the connection has been established.""" return ld and ld.connected() ### Members ### def create_member(username, password, name, program, email, club_rep=False): """ Creates a UNIX user account with options tailored to CSC members. Parameters: username - the desired UNIX username password - the desired UNIX password name - the member's real name program - the member's program of study club_rep - whether the user is a club rep email - email to place in .forward Exceptions: InvalidArgument - on bad account attributes provided Returns: the uid number of the new account See: create() """ # check username format if not username or not re.match(cfg['username_regex'], username): raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex'])) # check password length if not password or len(password) < cfg['min_password_length']: raise InvalidArgument("password", "", "too short (minimum %d characters)" % cfg['min_password_length']) try: request = ceo_pb2.AddUser() request.username = username request.password = password request.realname = name request.program = program request.email = email if club_rep: request.type = ceo_pb2.AddUser.CLUB_REP else: request.type = ceo_pb2.AddUser.MEMBER out = remote.run_remote('adduser', request.SerializeToString()) response = ceo_pb2.AddUserResponse() response.ParseFromString(out) if any(message.status != 0 for message in response.messages): raise MemberException('\n'.join(message.message for message in response.messages)) except remote.RemoteException, e: raise MemberException(e) except OSError, e: raise MemberException(e) def check_email(email): match = re.match('^\S+?@(\S+)$', email) if not match: return 'Invalid email address' # some characters are treated specially in .forward for c in email: if c in ('"', "'", ',', '|', '$', '/', '#', ':'): return 'Invalid character in address: %s' % c # Start by searching for host record host = match.group(1) try: ip = socket.getaddrinfo(host, None) except: # Check for MX record try: dns.resolver.query(host, 'MX') except: return 'Invalid host: %s' % host def current_email(username): fwdpath = '%s/%s/.forward' % (cfg['member_home'], username) try: fwd = open(fwdpath).read().strip() if not check_email(fwd): return fwd except OSError: pass except IOError: pass def change_email(username, forward): try: request = ceo_pb2.UpdateMail() request.username = username request.forward = forward out = remote.run_remote('mail', request.SerializeToString()) response = ceo_pb2.AddUserResponse() response.ParseFromString(out) if any(message.status != 0 for message in response.messages): return '\n'.join(message.message for message in response.messages) except remote.RemoteException, e: raise MemberException(e) except OSError, e: raise MemberException(e) def get(userid): """ Look up attributes of a member by userid. Returns: a dictionary of attributes Example: get('mspang') -> { 'cn': [ 'Michael Spang' ], 'program': [ 'Computer Science' ], ... } """ return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base']) def get_group(group): """ Look up group by groupname Returns a dictionary of group attributes """ return ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base']) def uid2dn(uid): return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base']) def list_term(term): """ Build a list of members in a term. Parameters: term - the term to match members against Returns: a list of members Example: list_term('f2006'): -> { 'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... }, 'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... }, ... } """ members = ldapi.search(ld, cfg['ldap_users_base'], '(&(objectClass=member)(term=%s))', [ term ]) return dict([(member[0], member[1]) for member in members]) def list_name(name): """ Build a list of members with matching names. Parameters: name - the name to match members against Returns: a list of member dictionaries Example: list_name('Spang'): -> { 'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... }, ... ] """ members = ldapi.search(ld, cfg['ldap_users_base'], '(&(objectClass=member)(cn~=%s))', [ name ]) return dict([(member[0], member[1]) for member in members]) def list_group(group): """ Build a list of members in a group. Parameters: group - the group to match members against Returns: a list of member dictionaries Example: list_name('syscom'): -> { 'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... }, ... ] """ members = group_members(group) ret = {} if members: for member in members: info = get(member) if info: ret[uid2dn(member)] = info return ret def list_all(): """ Build a list of all members Returns: a list of member dictionaries Example: list_name('Spang'): -> { 'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... }, ... ] """ members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)') return dict([(member[0], member[1]) for member in members]) def list_positions(): """ Build a list of positions Returns: a list of positions and who holds them Example: list_positions(): -> { 'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ], ... ] """ members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)') positions = {} for (_, member) in members: for position in member['position']: if not position in positions: positions[position] = {} positions[position][member['uid'][0]] = member return positions def set_position(position, members): """ Sets a position Parameters: position - the position to set members - an array of members that hold the position Example: set_position('president', ['dtbartle']) """ res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(&(objectClass=member)(position=%s))' % ldapi.escape(position)) old = set([ member['uid'][0] for (_, member) in res ]) new = set(members) mods = { 'del': set(old) - set(new), 'add': set(new) - set(old), } if len(mods['del']) == 0 and len(mods['add']) == 0: return for action in ['del', 'add']: for userid in mods[action]: dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base']) entry1 = {'position' : [position]} entry2 = {} #{'position' : []} entry = () if action == 'del': entry = (entry1, entry2) elif action == 'add': entry = (entry2, entry1) mlist = ldapi.make_modlist(entry[0], entry[1]) ld.modify_s(dn, mlist) def change_group_member(action, group, userid): user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base']) group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base']) entry1 = {'uniqueMember' : []} entry2 = {'uniqueMember' : [user_dn]} entry = [] if action == 'add' or action == 'insert': entry = (entry1, entry2) elif action == 'remove' or action == 'delete': entry = (entry2, entry1) else: raise InvalidArgument("action", action, "invalid action") mlist = ldapi.make_modlist(entry[0], entry[1]) ld.modify_s(group_dn, mlist) ### Shells ### def get_shell(userid): member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base']) if not member: raise NoSuchMember(userid) if 'loginShell' not in member: return return member['loginShell'][0] def get_shells(): return [ sh for sh in open(cfg['shells_file']).read().split("\n") if sh and sh[0] == '/' and not '#' in sh and os.access(sh, os.X_OK) ] def set_shell(userid, shell): if not shell in get_shells(): raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file']) ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ]) ### Clubs ### def create_club(username, name): """ Creates a UNIX user account with options tailored to CSC-hosted clubs. Parameters: username - the desired UNIX username name - the club name Exceptions: InvalidArgument - on bad account attributes provided Returns: the uid number of the new account See: create() """ # check username format if not username or not re.match(cfg['username_regex'], username): raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex'])) try: request = ceo_pb2.AddUser() request.type = ceo_pb2.AddUser.CLUB request.username = username request.realname = name out = remote.run_remote('adduser', request.SerializeToString()) response = ceo_pb2.AddUserResponse() response.ParseFromString(out) if any(message.status != 0 for message in response.messages): raise MemberException('\n'.join(message.message for message in response.messages)) except remote.RemoteException, e: raise MemberException(e) except OSError, e: raise MemberException(e) ### Terms ### def register(userid, term_list): """ Registers a member for one or more terms. Parameters: userid - the member's username term_list - the term to register for, or a list of terms Exceptions: InvalidTerm - if a term is malformed Example: register(3349, "w2007") Example: register(3349, ["w2007", "s2007"]) """ user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base']) if type(term_list) in (str, unicode): term_list = [ term_list ] ldap_member = get(userid) if ldap_member and 'term' not in ldap_member: ldap_member['term'] = [] if not ldap_member: raise NoSuchMember(userid) new_member = ldap_member.copy() new_member['term'] = new_member['term'][:] for term in term_list: # check term syntax if not re.match('^[wsf][0-9]{4}$', term): raise InvalidTerm(term) # add the term to the entry if not term in ldap_member['term']: new_member['term'].append(term) mlist = ldapi.make_modlist(ldap_member, new_member) ld.modify_s(user_dn, mlist) def register_nonmember(userid, term_list): """Registers a non-member for one or more terms.""" user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base']) if type(term_list) in (str, unicode): term_list = [ term_list ] ldap_member = get(userid) if not ldap_member: raise NoSuchMember(userid) if 'term' not in ldap_member: ldap_member['term'] = [] if 'nonMemberTerm' not in ldap_member: ldap_member['nonMemberTerm'] = [] new_member = ldap_member.copy() new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:] for term in term_list: # check term syntax if not re.match('^[wsf][0-9]{4}$', term): raise InvalidTerm(term) # add the term to the entry if not term in ldap_member['nonMemberTerm'] \ and not term in ldap_member['term']: new_member['nonMemberTerm'].append(term) mlist = ldapi.make_modlist(ldap_member, new_member) ld.modify_s(user_dn, mlist) def registered(userid, term): """ Determines whether a member is registered for a term. Parameters: userid - the member's username term - the term to check Returns: whether the member is registered Example: registered("mspang", "f2006") -> True """ member = get(userid) if not member is None: return 'term' in member and term in member['term'] else: return False def group_members(group): """ Returns a list of group members """ group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base']) if group and 'uniqueMember' in group: r = re.compile('^uid=([^,]*)') return map(lambda x: r.match(x).group(1), group['uniqueMember']) return [] def expired_accounts(): members = ldapi.search(ld, cfg['ldap_users_base'], '(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' % (terms.current(), terms.current())) return dict([(member[0], member[1]) for member in members]) def send_account_expired_email(name, email): args = [ cfg['expire_hook'], name, email ] os.spawnv(os.P_WAIT, cfg['expire_hook'], args) def subscribe_to_mailing_list(name): member = get(name) if member is not None: return remote.run_remote('mailman', name) else: return 'Error: member does not exist'