605 lines
16 KiB
Python
605 lines
16 KiB
Python
"""
|
|
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
|
|
|
|
|
|
### 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", "<hidden>", "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
|
|
|
|
host = match.group(1)
|
|
try:
|
|
ip = socket.gethostbyname(host)
|
|
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'
|