commit dfc747f9c6db8cad7478aba46cd2a6269a8d682c Author: Michael Spang Date: Sat Jan 27 18:41:51 2007 -0500 Initial import (version 0.1). diff --git a/bin/ceo b/bin/ceo new file mode 100755 index 0000000..c8847da --- /dev/null +++ b/bin/ceo @@ -0,0 +1,24 @@ +#!/usr/bin/python2.4 -- + +import os, sys + +safe_environment = ['LOGNAME', 'USERNAME', 'USER', 'HOME', + 'TERM', 'LANG', 'LC_ALL', 'LC_COLLATE', + 'LC_CTYPE', 'LC_MESSAGE', 'LC_MONETARY', + 'LC_NUMERIC', 'LC_TIME', 'UID', 'GID', + 'SSH_CONNECTION', 'SSH_AUTH_SOCK', + 'SSH_CLIENT'] + +for key in os.environ.keys(): + if not key in safe_environment: + del os.environ[key] + +os.environ['PATH'] = '/bin:/usr/bin' + +for dir in sys.path[:]: + if not dir.find('/usr') == 0 or dir.find('/usr/local') == 0: + while dir in sys.path: + sys.path.remove(dir) + +import csc.apps.legacy.main +csc.apps.legacy.main.run() diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..a5c8c99 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +csc (0.1) unstable; urgency=low + + * Initial Release. + + -- Michael Spang Thu, 28 Dec 2006 04:07:03 -0500 + diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..b8626c4 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +4 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..535cace --- /dev/null +++ b/debian/control @@ -0,0 +1,14 @@ +Source: csc +Section: admin +Priority: optional +Maintainer: Michael Spang +Build-Depends: debhelper (>= 4.0.0) +Standards-Version: 3.6.1 + +Package: csc +Architecture: any +Depends: python, python2.4, python2.4-ldap, python2.4-pygresql, krb5-user, less +Description: Computer Science Club Administrative Utilities + This package contains the CSC Electronic Office + and other Computer Science Club administrative + programs. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..b8c9741 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,31 @@ +This package was debianized by mspang on +Thu, 28 Dec 2006 04:07:03 -0500. + +Copyright (c) 2006, 2007 Michael Spang +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of the University of Waterloo Computer Science Club + nor the names of its contributors may be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..b69af8d --- /dev/null +++ b/debian/rules @@ -0,0 +1,66 @@ +#!/usr/bin/make -f + +PYTHON := python2.4 + +configure: + +build: build-stamp + +build-stamp: + mkdir build + $(CC) -DFULL_PATH=\"/usr/lib/csc/ceo\" -o build/ceo misc/setuid-prog.c + touch build-stamp + +clean: + dh_testdir + dh_testroot + dh_clean + rm -f build-stamp + rm -rf build/ + find py/ -name '*.pyc' -print0 | xargs -0 rm -f + +install: build + dh_testdir + dh_testroot + dh_clean -k + + # configuration files will contain sensitive information + chmod 600 etc/csc/* + + dh_installdirs etc usr/lib/$(PYTHON)/site-packages usr/share/csc \ + usr/lib/csc usr/bin + dh_install -X.svn -X.pyc py/csc usr/lib/$(PYTHON)/site-packages/ + dh_install -X.svn -X.pyc etc/csc etc/ + dh_install -X.svn -X.pyc sql/* usr/share/csc/ + + dh_install -X.svn -X.pyc bin/ceo usr/lib/csc/ + dh_install -X.svn -X.pyc build/ceo usr/bin/ + + +binary-indep: build install + dh_testdir + dh_testroot + dh_installchangelogs + dh_installdocs docs/* + dh_installexamples + dh_install +# dh_installlogrotate +# dh_installcron + dh_installman + dh_link + dh_strip + dh_compress + dh_fixperms +# dh_perl +# dh_python +# dh_makeshlibs + dh_installdeb + dh_shlibdeps + dh_gencontrol + dh_md5sums + dh_builddeb + +binary: binary-indep binary-arch +.PHONY: build clean binary-indep binary-arch binary install configure + +binary-arch: build install diff --git a/docs/BUGS b/docs/BUGS new file mode 100644 index 0000000..442acd9 --- /dev/null +++ b/docs/BUGS @@ -0,0 +1,8 @@ + +Bugs and Caveats +================ + +CEO: + - curses does not draw borders/lines correctly in a screen session + - windows don't always clear properly + - the menu is not redrawn between windows and therefore a gap may grow there diff --git a/etc/csc/accounts.cf b/etc/csc/accounts.cf new file mode 100644 index 0000000..cfe64ed --- /dev/null +++ b/etc/csc/accounts.cf @@ -0,0 +1,35 @@ +# $Id: accounts.cf 45 2007-01-02 01:39:10Z mspang $ +# CSC Accounts Configuration + +### Account Options ### + +minimum_id = 20000 +maximum_id = 40000 + +shell = "/bin/bash" +home = "/users" +gid = 100 + + +### LDAP Configuration ### + +server_url = "ldap:///" + +users_base = "ou=People,dc=csclub,dc=uwaterloo,dc=ca" +groups_base = "ou=Group,dc=csclub,dc=uwaterloo,dc=ca" + +bind_dn = "cn=ceo,dc=csclub,dc=uwaterloo,dc=ca" +bind_password = "secret" + + +### Kerberos Configuration ### + +realm = "CSCLUB.UWATERLOO.CA" +principal = "ceo/admin@CSCLUB.UWATERLOO.CA" +keytab = "/etc/csc/ceo.keytab" + + +### Validation Tuning ### + +username_regex = "^[a-z][-a-z0-9]*$" +realname_regex = "^[^,:=]*$" diff --git a/etc/csc/members.cf b/etc/csc/members.cf new file mode 100644 index 0000000..e984294 --- /dev/null +++ b/etc/csc/members.cf @@ -0,0 +1,15 @@ +# $Id: members.cf 45 2007-01-02 01:39:10Z mspang $ +# CSC Members Configuration + +### Database Configuration ### + +server = "localhost" +database = "ceo" + +user = "ceo" +password = "secret" + +### Validation Tuning ### + +studentid_regex = "^[0-9]{8}$" +realname_regex = "^[^,:=]*$" diff --git a/misc/setuid-prog.c b/misc/setuid-prog.c new file mode 100644 index 0000000..d850b47 --- /dev/null +++ b/misc/setuid-prog.c @@ -0,0 +1,176 @@ +/* + Template for a setuid program that calls a script. + + The script should be in an unwritable directory and should itself + be unwritable. In fact all parent directories up to the root + should be unwritable. The script must not be setuid, that's what + this program is for. + + This is a template program. You need to fill in the name of the + script that must be executed. This is done by changing the + definition of FULL_PATH below. + + There are also some rules that should be adhered to when writing + the script itself. + + The first and most important rule is to never, ever trust that the + user of the program will behave properly. Program defensively. + Check your arguments for reasonableness. If the user is allowed to + create files, check the names of the files. If the program depends + on argv[0] for the action it should perform, check it. + + Assuming the script is a Bourne shell script, the first line of the + script should be + #!/bin/sh - + The - is important, don't omit it. If you're using esh, the first + line should be + #!/usr/local/bin/esh -f + and for ksh, the first line should be + #!/usr/local/bin/ksh -p + The script should then set the variable IFS to the string + consisting of , , and . After this (*not* + before!), the PATH variable should be set to a reasonable value and + exported. Do not expect the PATH to have a reasonable value, so do + not trust the old value of PATH. You should then set the umask of + the program by calling + umask 077 # or 022 if you want the files to be readable + If you plan to change directories, you should either unset CDPATH + or set it to a good value. Setting CDPATH to just ``.'' (dot) is a + good idea. + If, for some reason, you want to use csh, the first line should be + #!/bin/csh -fb + You should then set the path variable to something reasonable, + without trusting the inherited path. Here too, you should set the + umask using the command + umask 077 # or 022 if you want the files to be readable +*/ + +#include +#include +#include +#include +#include +#include + +/* CONFIGURATION SECTION */ + +#ifndef FULL_PATH /* so that this can be specified from the Makefile */ +/* Uncomment the following line: +#define FULL_PATH "/full/path/of/script" +* Then comment out the #error line. */ +#error "You must define FULL_PATH somewhere" +#endif +#ifndef UMASK +#define UMASK 077 +#endif + +/* END OF CONFIGURATION SECTION */ + +#if defined(__STDC__) && defined(__sgi) +#define environ _environ +#endif + +/* don't change def_IFS */ +char def_IFS[] = "IFS= \t\n"; +/* you may want to change def_PATH, but you should really change it in */ +/* your script */ +#ifdef __sgi +char def_PATH[] = "PATH=/usr/bsd:/usr/bin:/bin:/usr/local/bin:/usr/sbin"; +#else +char def_PATH[] = "PATH=/usr/ucb:/usr/bin:/bin:/usr/local/bin"; +#endif +/* don't change def_CDPATH */ +char def_CDPATH[] = "CDPATH=."; +/* don't change def_ENV */ +char def_ENV[] = "ENV=:"; + +/* + This function changes all environment variables that start with LD_ + into variables that start with XD_. This is important since we + don't want the script that is executed to use any funny shared + libraries. + + The other changes to the environment are, strictly speaking, not + needed here. They can safely be done in the script. They are done + here because we don't trust the script writer (just like the script + writer shouldn't trust the user of the script). + If IFS is set in the environment, set it to space,tab,newline. + If CDPATH is set in the environment, set it to ``.''. + Set PATH to a reasonable default. +*/ +void +clean_environ(void) +{ + char **p; + extern char **environ; + + for (p = environ; *p; p++) { + if (strncmp(*p, "LD_", 3) == 0) + **p = 'X'; + else if (strncmp(*p, "_RLD", 4) == 0) + **p = 'X'; + else if (strncmp(*p, "PYTHON", 6) == 0) + **p = 'X'; + else if (strncmp(*p, "IFS=", 4) == 0) + *p = def_IFS; + else if (strncmp(*p, "CDPATH=", 7) == 0) + *p = def_CDPATH; + else if (strncmp(*p, "ENV=", 4) == 0) + *p = def_ENV; + } + putenv(def_PATH); +} + +int +main(int argc, char **argv) +{ + struct stat statb; + gid_t egid = getegid(); + uid_t euid = geteuid(); + + /* + Sanity check #1. + This check should be made compile-time, but that's not possible. + If you're sure that you specified a full path name for FULL_PATH, + you can omit this check. + */ + if (FULL_PATH[0] != '/') { + fprintf(stderr, "%s: %s is not a full path name\n", argv[0], + FULL_PATH); + fprintf(stderr, "You can only use this wrapper if you\n"); + fprintf(stderr, "compile it with an absolute path.\n"); + exit(1); + } + + /* + Sanity check #2. + Check that the owner of the script is equal to either the + effective uid or the super user. + */ + if (stat(FULL_PATH, &statb) < 0) { + perror("stat"); + exit(1); + } + if (statb.st_uid != 0 && statb.st_uid != euid) { + fprintf(stderr, "%s: %s has the wrong owner\n", argv[0], + FULL_PATH); + fprintf(stderr, "The script should be owned by root,\n"); + fprintf(stderr, "and shouldn't be writeable by anyone.\n"); + exit(1); + } + + if (setregid(egid, egid) < 0) + perror("setregid"); + if (setreuid(euid, euid) < 0) + perror("setreuid"); + + clean_environ(); + + umask(UMASK); + + while (**argv == '-') /* don't let argv[0] start with '-' */ + (*argv)++; + execv(FULL_PATH, argv); + fprintf(stderr, "%s: could not execute the script\n", argv[0]); + exit(1); +} diff --git a/py/csc/__init__.py b/py/csc/__init__.py new file mode 100644 index 0000000..0583735 --- /dev/null +++ b/py/csc/__init__.py @@ -0,0 +1,19 @@ +# $Id: __init__.py 24 2006-12-18 20:23:12Z mspang $ +""" +PyCSC - CSC Administrative Utilities + +Member Management: + + ceo - legacy ceo interface + +Account Management: + + ceo - legacy ceo interface + +Modules: + + admin - administrative code (member and account management) + backend - backend interface code + ui - user interface code + +""" diff --git a/py/csc/admin/__init__.py b/py/csc/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/py/csc/admin/accounts.py b/py/csc/admin/accounts.py new file mode 100644 index 0000000..d9975f3 --- /dev/null +++ b/py/csc/admin/accounts.py @@ -0,0 +1,232 @@ +# $Id: accounts.py 44 2006-12-31 07:09:27Z mspang $ +# UNIX Accounts Module +import re +from csc.backend import ldapi, krb +from csc.lib import read_config + +CONFIG_FILE = '/etc/csc/accounts.cf' + +cfg = {} + +# error constants +SUCCESS = 0 +LDAP_EXISTS = 1 +LDAP_NO_IDS = 2 +LDAP_NO_USER = 3 +KRB_EXISTS = 5 +KRB_NO_USER = 6 +BAD_USERNAME = 8 +BAD_REALNAME = 9 + +# error messages +errors = [ "Success", "LDAP: entry exists", + "LDAP: no user ids available", "LDAP: no such entry", + "KRB: principal exists", "KRB: no such principal", + "Invalid username", "Invalid real name"] + + +class AccountException(Exception): + """Exception class for account-related errors.""" + + +def load_configuration(): + """Load Accounts Configuration.""" + + # configuration already loaded? + if len(cfg) > 0: + return + + # read in the file + cfg_tmp = read_config(CONFIG_FILE) + + if not cfg_tmp: + raise AccountException("unable to read configuration file: %s" % CONFIG_FILE) + + # check that essential fields are completed + mandatory_fields = [ 'minimum_id', 'maximum_id', 'shell', 'home', + 'gid', 'server_url', 'users_base', 'groups_base', 'bind_dn', + 'bind_password', 'realm', 'principal', 'keytab', 'username_regex', + 'realname_regex' + ] + + for field in mandatory_fields: + if not field in cfg_tmp: + raise AccountException("missing configuration option: %s" % field) + if not cfg_tmp[field]: + raise AccountException("null configuration option: %s" % field) + + # check that numeric fields are ints + numeric_fields = [ 'minimum_id', 'maximum_id', 'gid' ] + + for field in numeric_fields: + if not type(cfg_tmp[field]) in (int, long): + raise AccountException("non-numeric value for configuration option: %s" % field) + + # update the current configuration with the loaded values + cfg.update(cfg_tmp) + + +def create_account(username, password, realname='', gecos_other=''): + """ + Creates a UNIX account for a member. This involves + first creating a directory entry, then creating + a Kerberos principal. + + Parameters: + username - UNIX username for the member + realname - real name of the member + password - password for the account + + Exceptions: + LDAPException - on LDAP failure + KrbException - on Kerberos failure + + Returns: + SUCCESS - on success + BAD_REALNAME - on badly formed real name + BAD_USERNAME - on badly formed user name + LDAP_EXISTS - when the user exists in LDAP + LDAP_NO_IDS - when no user ids are free + KRB_EXISTS - when the user exists in Kerberos + """ + + # Load Configuration + load_configuration() + + ### Connect to the Backends ### + + ldap_connection = ldapi.LDAPConnection() + krb_connection = krb.KrbConnection() + + try: + + # connect to the LDAP server + ldap_connection.connect(cfg['server_url'], cfg['bind_dn'], cfg['bind_password'], cfg['users_base'], cfg['groups_base']) + + # connect to the Kerberos master server + krb_connection.connect(cfg['principal'], cfg['keytab']) + + ### Sanity-checks ### + + # check the username and realame for validity + if not re.match(cfg['username_regex'], username): + return BAD_USERNAME + if not re.match(cfg['realname_regex'], realname): + return BAD_REALNAME + + # see if user exists in LDAP + if ldap_connection.user_lookup(username): + return LDAP_EXISTS + + # determine the first available userid + userid = ldap_connection.first_id(cfg['minimum_id'], cfg['maximum_id']) + if not userid: return LDAP_NO_IDS + + # build principal name from username + principal = username + '@' + cfg['realm'] + + # see if user exists in Kerberos + if krb_connection.get_principal(principal): + return KRB_EXISTS + + ### User creation ### + + # process gecos_other (used to store memberid) + if gecos_other: + gecos_other = ',' + str(gecos_other) + + # account information defaults + shell = cfg['shell'] + home = cfg['home'] + '/' + username + gecos = realname + ',,,' + gecos_other + gid = cfg['gid'] + + # create the LDAP entry + ldap_connection.user_add(username, realname, shell, userid, gid, home, gecos) + + # create the Kerberos principal + krb_connection.add_principal(principal, password) + + finally: + ldap_connection.disconnect() + krb_connection.disconnect() + + return SUCCESS + + +def delete_account(username): + """ + Deletes the UNIX account of a member. + + Parameters: + username - UNIX username for the member + + Exceptions: + LDAPException - on LDAP failure + KrbException - on Kerberos failure + + Returns: + SUCCESS - on success + LDAP_NO_USER - when the user does not exist in LDAP + KRB_NO_USER - when the user does not exist in Kerberos + """ + + # Load Configuration + load_configuration() + + ### Connect to the Backends ### + + ldap_connection = ldapi.LDAPConnection() + krb_connection = krb.KrbConnection() + + try: + + # connect to the LDAP server + ldap_connection.connect(cfg['server_url'], cfg['bind_dn'], cfg['bind_password'], cfg['users_base'], cfg['groups_base']) + + # connect to the Kerberos master server + krb_connection.connect(cfg['principal'], cfg['keytab']) + + ### Sanity-checks ### + + # ensure user exists in LDAP + if not ldap_connection.user_lookup(username): + return LDAP_NO_USER + + # build principal name from username + principal = username + '@' + cfg['realm'] + + # see if user exists in Kerberos + if not krb_connection.get_principal(principal): + return KRB_NO_USER + + ### User deletion ### + + # delete the LDAP entry + ldap_connection.user_delete(username) + + # delete the Kerberos principal + krb_connection.delete_principal(principal) + + finally: + ldap_connection.disconnect() + krb_connection.disconnect() + + return SUCCESS + + + +### Tests ### + +if __name__ == '__main__': + + # A word of notice: this test creates a _working_ account (and then deletes it). + # If deletion fails it must be cleaned up manually. + + # a bit of salt so the test account is reasonably tough to crack + import random + pw = str(random.randint(100000000000000000, 999999999999999999)) + + print "running create_account('testuser', ..., 'Test User', ...)", "->", errors[create_account('testuser', pw, 'Test User')] + print "running delete_account('testuser')", "->", errors[delete_account('testuser')] + diff --git a/py/csc/admin/members.py b/py/csc/admin/members.py new file mode 100644 index 0000000..326a7de --- /dev/null +++ b/py/csc/admin/members.py @@ -0,0 +1,426 @@ +# $Id: members.py 44 2006-12-31 07:09:27Z mspang $ +""" +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 re +from csc.admin import terms +from csc.backend import db +from csc.lib import read_config + + + + +### Configuration + +CONFIG_FILE = '/etc/csc/members.cf' + +cfg = {} + + +def load_configuration(): + """Load Members Configuration""" + + # configuration already loaded? + if len(cfg) > 0: + return + + # read in the file + cfg_tmp = read_config(CONFIG_FILE) + + if not cfg_tmp: + raise MemberException("unable to read configuration file: %s" + % CONFIG_FILE) + + # check that essential fields are completed + mandatory_fields = [ 'server', 'database', 'user', 'password' ] + + for field in mandatory_fields: + if not field in cfg_tmp: + raise MemberException("missing configuratino option: %s" % field) + if not cfg_tmp[field]: + raise MemberException("null configuration option: %s" %field) + + # update the current configuration with the loaded values + cfg.update(cfg_tmp) + + + +### Exceptions ### + +class MemberException(Exception): + """Exception class for member-related errors.""" + +class DuplicateStudentID(MemberException): + """Exception class for student ID conflicts.""" + pass + +class InvalidStudentID(MemberException): + """Exception class for malformed student IDs.""" + pass + +class InvalidTerm(MemberException): + """Exception class for malformed terms.""" + pass + +class NoSuchMember(MemberException): + """Exception class for nonexistent members.""" + pass + + + +### Connection Management ### + +# global database connection +connection = db.DBConnection() + + +def connect(): + """Connect to PostgreSQL.""" + + load_configuration() + + connection.connect(cfg['server'], cfg['database']) + + +def disconnect(): + """Disconnect from PostgreSQL.""" + + connection.disconnect() + + +def connected(): + """Determine whether the connection has been established.""" + + return connection.connected() + + +### Member Table ### + +def new(realname, studentid=None, program=None): + """ + Registers a new CSC member. The member is added + to the members table and registered for the current + term. + + Parameters: + realname - the full real name of the member + studentid - the student id number of the member + program - the program of study of the member + + Returns: the memberid of the new member + + Exceptions: + DuplicateStudentID - if the student id already exists in the database + InvalidStudentID - if the student id is malformed + + Example: new("Michael Spang", program="CS") -> 3349 + """ + + # blank attributes should be NULL + if studentid == '': studentid = None + if program == '': program = None + + # check the student id format + regex = '^[0-9]{8}$' + if studentid != None and not re.match(regex, str(studentid)): + raise InvalidStudentID("student id is invalid: %s" % studentid) + + # check for duplicate student id + member = connection.select_member_by_studentid(studentid) + if member: + raise DuplicateStudentID("student id exists in database: %s" % studentid) + + # add the member + memberid = connection.insert_member(realname, studentid, program) + + # register them for this term + connection.insert_term(memberid, terms.current()) + + # commit the transaction + connection.commit() + + return memberid + + +def get(memberid): + """ + Look up attributes of a member by memberid. + + Parameters: + memberid - the member id number + + Returns: a dictionary of attributes + + Example: get(3349) -> { + 'memberid': 3349, + 'name': 'Michael Spang', + 'program': 'Computer Science', + ... + } + """ + + return connection.select_member_by_id(memberid) + + +def get_userid(userid): + """ + Look up attributes of a member by userid. + + Parameters: + userid - the UNIX user id + + Returns: a dictionary of attributes + + Example: get('mspang') -> { + 'memberid': 3349, + 'name': 'Michael Spang', + 'program': 'Computer Science', + ... + } + """ + + return connection.select_member_by_account(userid) + + +def get_studentid(studentid): + """ + Look up attributes of a member by studnetid. + + Parameters: + studentid - the student ID number + + Returns: a dictionary of attributes + + Example: get(...) -> { + 'memberid': 3349, + 'name': 'Michael Spang', + 'program': 'Computer Science', + ... + } + """ + + return connection.select_member_by_studentid(studentid) + + +def list_term(term): + """ + Build a list of members in a term. + + Parameters: + term - the term to match members against + + Returns: a list of member dictionaries + + Example: list_term('f2006'): -> [ + { 'memberid': 3349, ... }, + { 'memberid': ... }. + ... + ] + """ + + # retrieve a list of memberids in term + memberlist = connection.select_members_by_term(term) + + # convert the list of memberids to a list of dictionaries + memberlist = map(connection.select_member_by_id, memberlist) + + return memberlist + + +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'): -> [ + { 'memberid': 3349, ... }, + { 'memberid': ... }, + ... + ] + """ + + # retrieve a list of memberids matching name + memberlist = connection.select_members_by_name(name) + + # convert the list of memberids to a list of dictionaries + memberlist = map(connection.select_member_by_id, memberlist) + + return memberlist + + +def delete(memberid): + """ + Erase all records of a member. + + Note: real members are never removed + from the database + + Parameters: + memberid - the member id number + + Returns: attributes and terms of the + member in a tuple + + Example: delete(0) -> ({ 'memberid': 0, name: 'Calum T. Dalek' ...}, ['s1993']) + """ + + # save member data + member = connection.select_member_by_id(memberid) + term_list = connection.select_terms(memberid) + + # remove data from the db + connection.delete_term_all(memberid) + connection.delete_member(memberid) + connection.commit() + + return (member, term_list) + + +def update(member): + """ + Update CSC member attributes. None is NULL. + + Parameters: + member - a dictionary with member attributes as + returned by get, possibly omitting some + attributes. member['memberid'] must exist + and be valid. + + Exceptions: + NoSuchMember - if the member id does not exist + InvalidStudentID - if the student id number is malformed + DuplicateStudentID - if the student id number exists + + Example: update( {'memberid': 3349, userid: 'mspang'} ) + """ + + if member.has_key('studentid') and member['studentid'] != None: + + studentid = member['studentid'] + + # check the student id format + regex = '^[0-9]{8}$' + if studentid != None and not re.match(regex, str(studentid)): + raise InvalidStudentID("student id is invalid: %s" % studentid) + + # check for duplicate student id + member = connection.select_member_by_studentid(studentid) + if member: + raise DuplicateStudentID("student id exists in database: %s" % + studentid) + + # not specifying memberid is a bug + if not member.has_key('memberid'): + raise Exception("no member specified in call to update") + memberid = member['memberid'] + + # see if member exists + old_member = connection.select_member_by_id(memberid) + if not old_member: + raise NoSuchMember("memberid does not exist in database: %d" % + memberid) + + # do the update + connection.update_member(member) + + # commit the transaction + connection.commit() + + + +### Term Table ### + +def register(memberid, term_list): + """ + Registers a member for one or more terms. + + Parameters: + memberid - the member id number + 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"]) + """ + + if not type(term_list) in (list, tuple): + term_list = [ term_list ] + + for term in term_list: + + # check term syntax + if not re.match('^[wsf][0-9]{4}$', term): + raise InvalidTerm("term is invalid: %s" % term) + + # add term to database + connection.insert_term(memberid, term) + + connection.commit() + + +def registered(memberid, term): + """ + Determines whether a member is registered + for a term. + + Parameters: + memberid - the member id number + term - the term to check + + Returns: whether the member is registered + + Example: registered(3349, "f2006") -> True + """ + + return connection.select_term(memberid, term) != None + + +def terms_list(memberid): + """ + Retrieves a list of terms a member is + registered for. + + Parameters: + memberid - the member id number + + Returns: list of term strings + + Example: registered(0) -> 's1993' + """ + + return connection.select_terms(memberid) + + + +### Tests ### + +if __name__ == '__main__': + + connect() + + + sid = new("Test User", "99999999", "CS") + + assert registered(id, terms.current()) + print get(sid) + register(sid, terms.next(terms.current())) + assert registered(sid, terms.next(terms.current())) + print terms_list(sid) + print get(sid) + print delete(sid) diff --git a/py/csc/admin/terms.py b/py/csc/admin/terms.py new file mode 100644 index 0000000..3f9cae2 --- /dev/null +++ b/py/csc/admin/terms.py @@ -0,0 +1,252 @@ +# $Id: terms.py 44 2006-12-31 07:09:27Z mspang $ +""" +Terms Routines + +This module contains functions for manipulating +terms, such as determining the current term, +finding the next or previous term, converting +dates to terms, and more. +""" +import time, datetime, re + +# year to count terms from +EPOCH = 1970 + +# seasons list +SEASONS = [ 'w', 's', 'f' ] + + +def valid(term): + """ + Determines whether a term is well-formed: + + Parameters: + term - the term string + + Returns: whether the term is valid (boolean) + + Example: valid("f2006") -> True + """ + + regex = '^[wsf][0-9]{4}$' + return re.match(regex, term) != None + + +def parse(term): + """Helper function to convert a term string to the number of terms + since the epoch. Such numbers are intended for internal use only.""" + + if not valid(term): + raise Exception("malformed term: %s" % term) + + year = int( term[1:] ) + season = SEASONS.index( term[0] ) + + return (year - EPOCH) * len(SEASONS) + season + + +def generate(term): + """Helper function to convert a year and season to a term string.""" + + year = int(term / len(SEASONS)) + EPOCH + season = term % len(SEASONS) + + return "%s%04d" % ( SEASONS[season], year ) + + +def next(term): + """ + Returns the next term. (convenience function) + + Parameters: + term - the term string + + Retuns: the term string of the following term + + Example: next("f2006") -> "w2007" + """ + + return add(term, 1) + + +def previous(term): + """ + Returns the previous term. (convenience function) + + Parameters: + term - the term string + + Returns: the term string of the preceding term + + Example: previous("f2006") -> "s2006" + """ + + return add(term, -1) + + +def add(term, offset): + """ + Calculates a term relative to some base term. + + Parameters: + term - the base term + offset - the number of terms since term (may be negative) + + Returns: the term that comes offset terms after term + """ + + return generate(parse(term) + offset) + + +def delta(initial, final): + """ + Calculates the distance between two terms. + It should be true that add(a, delta(a, b)) == b. + + Parameters: + initial - the base term + final - the term at some offset from the base term + + Returns: the offset of final relative to initial + """ + + return parse(final) - parse(initial) + + +def compare(first, second): + """ + Compares two terms. This function is suitable + for use with list.sort(). + + Parameters: + first - base term for comparison + second - term to compare to + + Returns: > 0 (if first > second) + = 0 (if first == second) + < 0 (if first < second) + """ + return delta(second, first) + + +def interval(base, count): + """ + Returns a list of adjacent terms. + + Parameters: + base - the first term in the interval + count - the number of terms to include + + Returns: a list of count terms starting with initial + + Example: interval('f2006', 3) -> [ 'f2006', 'w2007', 's2007' ] + """ + + terms = [] + + for num in xrange(count): + terms.append( add(base, num) ) + + return terms + + +def tstamp(timestamp): + """Helper to convert seconds since the epoch + to terms since the epoch.""" + + # let python determine the month and year + date = datetime.date.fromtimestamp(timestamp) + + # determine season + if date.month <= 4: + season = SEASONS.index('w') + elif date.month <= 8: + season = SEASONS.index('s') + else: + season = SEASONS.index('f') + + return (date.year - EPOCH) * len(SEASONS) + season + + +def from_timestamp(timestamp): + """ + Converts a number of seconds since + the epoch to a number of terms since + the epoch. + + This function notes that: + WINTER = JANUARY to APRIL + SPRING = MAY TO AUGUST + FALL = SEPTEMBER TO DECEMBER + + Parameters: + timestamp - number of seconds since the epoch + + Returns: the number of terms since the epoch + + Example: from_timestamp(1166135779) -> 'f2006' + """ + + return generate( tstamp(timestamp) ) + + +def curr(): + """Helper to determine the current term.""" + + return tstamp( time.time() ) + + +def current(): + """ + Determines the current term. + + Returns: current term + + Example: current() -> 'f2006' + """ + + return generate( curr() ) + + +def next_unregistered(registered): + """ + Find the first future or current unregistered term. + Intended as the 'default' for registrations. + + Parameters: + registered - a list of terms a member is registered for + + Returns: the next unregistered term + """ + + # get current term number + now = curr() + + # never registered -> current term is next + if len( registered) < 1: + return generate( now ) + + # return the first unregistered, or the current term (whichever is greater) + return generate(max([max(map(parse, registered))+1, now])) + + + +### Tests ### + +if __name__ == '__main__': + + assert parse('f2006') == 110 + assert generate(110) == 'f2006' + assert next('f2006') == 'w2007' + assert previous('f2006') == 's2006' + assert delta('f2006', 'w2007') == 1 + assert add('f2006', delta('f2006', 'w2010')) == 'w2010' + assert interval('f2006', 3) == ['f2006', 'w2007', 's2007'] + assert from_timestamp(1166135779) == 'f2006' + assert parse( current() ) >= 110 + assert next_unregistered( [current()] ) == next( current() ) + assert next_unregistered( [] ) == current() + assert next_unregistered( [previous(current())] ) == current() + assert next_unregistered( [add(current(), -2)] ) == current() + + print "All tests passed." "\n" diff --git a/py/csc/apps/__init__.py b/py/csc/apps/__init__.py new file mode 100644 index 0000000..09ddf85 --- /dev/null +++ b/py/csc/apps/__init__.py @@ -0,0 +1,9 @@ +# $Id: __init__.py 23 2006-12-18 20:14:51Z mspang $ +""" +User Interfaces + +This module contains frontends and related modules. +CEO's primary frontends are: + + legacy - aims to reproduce the curses UI of the previous CEO +""" diff --git a/py/csc/apps/legacy/__init__.py b/py/csc/apps/legacy/__init__.py new file mode 100644 index 0000000..18352d2 --- /dev/null +++ b/py/csc/apps/legacy/__init__.py @@ -0,0 +1,10 @@ +# $Id: __init__.py 23 2006-12-18 20:14:51Z mspang $ +""" +Legacy User Interface + +This module contains the legacy CEO user interface and related modules. +Important modules are: + + main.py - all of the main UI logic + helpers.py - user interface library routines +""" diff --git a/py/csc/apps/legacy/helpers.py b/py/csc/apps/legacy/helpers.py new file mode 100644 index 0000000..9d7cbaa --- /dev/null +++ b/py/csc/apps/legacy/helpers.py @@ -0,0 +1,412 @@ +# $Id: helpers.py 35 2006-12-28 05:14:05Z mspang $ +""" +Helpers for legacy User Interface + +This module contains numerous functions that are designed to immitate +the look and behavior of the previous CEO. Included is code for various +curses-based UI widgets that were provided by Perl 5's Curses and +Curses::Widgets libraries. + +Though attempts have been made to keep the UI bug-compatible with +the previous system, some compromises have been made. For example, +the input and textboxes draw 'OK' and 'Cancel' buttons where the old +CEO had them, but they are fake. That is, the buttons in the old +CEO were selectable but non-operational, but in the new CEO they are +not even selectable. +""" +import curses.ascii + +# key constants not defined in CURSES +KEY_RETURN = ord('\n') +KEY_ESCAPE = 27 +KEY_EOT = 4 + + +def center(parent_dim, child_dim): + """Helper for centering a length in a larget length.""" + return (parent_dim-child_dim)/2 + + +def read_input(wnd, offy, offx, width, maxlen, echo=True): + """ + Read user input within a confined region of a window. + + Basic line-editing is supported: + LEFT, RIGHT, HOME, and END move around. + BACKSPACE and DEL remove characters. + INSERT switches between insert and overwrite mode. + ESC and C-d abort input. + RETURN completes input. + + Parameters: + wnd - parent window for region + offy - the vertical offset for the beginning of the input region + offx - the horizontal offset for the beginning of the input region + width - the width of the region + maxlen - greatest number of characters to read (0 for no limit) + echo - boolean: whether to display typed characters + + Returns: the string, or None when the user aborts. + """ + + # turn on cursor + try: + curses.curs_set(1) + except: + pass + + # set keypad mode to allow UP, DOWN, etc + wnd.keypad(1) + + # the input string + input = "" + + # offset of cursor in input + # i.e. the next operation is applied at input[inputoff] + inputoff = 0 + + # display offset (for scrolling) + # i.e. the first character in the region is input[displayoff] + displayoff = 0 + + # insert mode (True) or overwrite mode (False) + insert = True + + while True: + + # echo mode, display the string + if echo: + # discard characters before displayoff, + # as the window may be scrolled to the right + substring = input[displayoff:] + + # pad the string with zeroes to overwrite stale characters + substring = substring + " " * (width - len(substring)) + + # display the substring + wnd.addnstr(offy, offx, substring, width) + + # await input + key = wnd.getch(offy, offx + inputoff - displayoff) + + # not echo mode, don't display the string + else: + # await input at arbitrary location + key = wnd.getch(offy, offx) + + # enter returns input + if key == KEY_RETURN: + return input + + # escape aborts input + elif key == KEY_ESCAPE: + return None + + # EOT (C-d) aborts if there is no input + elif key == KEY_EOT: + if len(input) == 0: + return None + + # backspace removes the previous character + elif key == curses.KEY_BACKSPACE: + if inputoff > 0: + + # remove the character immediately before the input offset + input = input[0:inputoff-1] + input[inputoff:] + inputoff -= 1 + + # move either the cursor or entire line of text left + if displayoff > 0: + displayoff -= 1 + + # delete removes the current character + elif key == curses.KEY_DC: + if inputoff < len(input): + + # remove the character at the input offset + input = input[0:inputoff] + input[inputoff+1:] + + # left moves the cursor one character left + elif key == curses.KEY_LEFT: + if inputoff > 0: + + # move the cursor to the left + inputoff -= 1 + + # scroll left if necessary + if inputoff < displayoff: + displayoff -= 1 + + # right moves the cursor one character right + elif key == curses.KEY_RIGHT: + if inputoff < len(input): + + # move the cursor to the right + inputoff += 1 + + # scroll right if necessary + if displayoff - inputoff == width: + displayoff += 1 + + # home moves the cursor to the first character + elif key == curses.KEY_HOME: + inputoff = 0 + displayoff = 0 + + # end moves the cursor past the last character + elif key == curses.KEY_END: + inputoff = len(input) + displayoff = len(input) - width + 1 + + # insert toggles insert/overwrite mode + elif key == curses.KEY_IC: + insert = not insert + + # other (printable) characters are added to the input string + elif curses.ascii.isprint(key): + if len(input) < maxlen or maxlen == 0: + + # insert mode: insert before current offset + if insert: + input = input[0:inputoff] + chr(key) + input[inputoff:] + + # overwrite mode: replace current offset + else: + input = input[0:inputoff] + chr(key) + input[inputoff+1:] + + # increment the input offset + inputoff += 1 + + # scroll right if necessary + if inputoff - displayoff == width: + displayoff += 1 + + +def inputbox(wnd, prompt, field_width, echo=True): + """Display a window for user input.""" + + wnd_height, wnd_width = wnd.getmaxyx() + height, width = 12, field_width + 7 + + # draw a window for the dialog + childy, childx = center(wnd_height-1, height)+1, center(wnd_width, width) + child_wnd = wnd.subwin(height, width, childy, childx) + child_wnd.clear() + child_wnd.border() + + # draw another window for the text box + texty, textx = center(height-1, 3)+1, center(width-1, width-5)+1 + textheight, textwidth = 3, width-5 + text_wnd = child_wnd.derwin(textheight, textwidth, texty, textx) + text_wnd.clear() + text_wnd.border() + + # draw the prompt + prompty, promptx = 2, 3 + child_wnd.addnstr(prompty, promptx, prompt, width-2) + + # draw the fake buttons + fakey, fakex = 9, width - 19 + child_wnd.addstr(fakey, fakex, "< OK > < Cancel >") + child_wnd.addch(fakey, fakex+2, "O", curses.A_UNDERLINE) + child_wnd.addch(fakey, fakex+9, "C", curses.A_UNDERLINE) + + # update the screen + child_wnd.noutrefresh() + text_wnd.noutrefresh() + curses.doupdate() + + # read an input string within the field region of text_wnd + inputy, inputx, inputwidth = 1, 1, textwidth - 2 + input = read_input(text_wnd, inputy, inputx, inputwidth, 0, echo) + + # erase the window + child_wnd.erase() + child_wnd.refresh() + + return input + + +def line_wrap(line, width): + """Wrap a string to a certain width (returns a list of strings).""" + + wrapped_lines = [] + tokens = line.split(" ") + tokens.reverse() + tmp = tokens.pop() + if len(tmp) > width: + wrapped_lines.append(tmp[0:width]) + tmp = tmp[width:] + while len(tokens) > 0: + token = tokens.pop() + if len(tmp) + len(token) + 1 <= width: + tmp += " " + token + elif len(token) > width: + tmp += " " + token[0:width-len(tmp)-1] + tokens.push(token[width-len(tmp)-1:]) + else: + wrapped_lines.append(tmp) + tmp = token + wrapped_lines.append(tmp) + return wrapped_lines + + +def msgbox(wnd, msg, title="Message"): + """Display a message in a window.""" + + # split message into a list of lines + lines = msg.split("\n") + + # determine the dimensions of the method + message_height = len(lines) + message_width = 0 + for line in lines: + if len(line) > message_width: + message_width = len(line) + + # ensure the window fits the title + if len(title) > message_width: + message_width = len(title) + + # maximum message width + parent_height, parent_width = wnd.getmaxyx() + max_message_width = parent_width - 8 + + # line-wrap if necessary + if message_width > max_message_width: + newlines = [] + for line in lines: + for newline in line_wrap(line, max_message_width): + newlines.append(newline) + lines = newlines + message_width = max_message_width + message_height = len(lines) + + # random padding that perl's curses adds + pad_width = 2 + + # create the outer window + outer_height, outer_width = message_height + 8, message_width + pad_width + 6 + outer_y, outer_x = center(parent_height+1, outer_height)-1, center(parent_width, outer_width) + outer_wnd = wnd.derwin(outer_height, outer_width, outer_y, outer_x) + outer_wnd.erase() + outer_wnd.border() + + # create the inner window + inner_height, inner_width = message_height + 2, message_width + pad_width + 2 + inner_y, inner_x = 2, center(outer_width, inner_width) + inner_wnd = outer_wnd.derwin(inner_height, inner_width, inner_y, inner_x) + inner_wnd.border() + + # display the title + outer_wnd.addstr(0, 1, " " + title + " ", curses.A_REVERSE | curses.A_BOLD) + + # display the message + for i in xrange(len(lines)): + inner_wnd.addnstr(i+1, 1, lines[i], message_width) + + # draw a solid block at the end of the first few lines + if i < len(lines) - 1: + inner_wnd.addch(i+1, inner_width-1, ' ', curses.A_REVERSE) + + # display the fake OK button + fakey, fakex = outer_height - 3, outer_width - 8 + outer_wnd.addstr(fakey, fakex, "< OK >", curses.A_REVERSE) + outer_wnd.addch(fakey, fakex+2, "O", curses.A_UNDERLINE | curses.A_REVERSE) + + # update display + outer_wnd.noutrefresh() + inner_wnd.noutrefresh() + curses.doupdate() + + # read a RETURN or ESC before returning + curses.curs_set(0) + outer_wnd.keypad(1) + while True: + key = outer_wnd.getch(0,0) + if key == KEY_RETURN or key == KEY_ESCAPE: + break + + # clear the window + outer_wnd.erase() + outer_wnd.refresh() + + +def menu(wnd, offy, offx, width, options, _acquire_wnd=None): + """ + Draw a menu and wait for a selection. + + Parameters: + wnd - parent window + offy - vertical offset for menu region + offx - horizontal offset for menu region + width - width of menu region + options - a list of selections + _acquire_wnd - hack to support resize: must be a function callback + that returns new values for wnd, offy, offx, height, + width. Unused if None. + + Returns: index into options that was selected + """ + + # the currently selected option + selected = 0 + + while True: + # disable cursor + curses.curs_set(0) + + # hack to support resize: recreate the + # parent window every iteration + if _acquire_wnd: + wnd, offy, offx, height, width = _acquire_wnd() + + # keypad mode so getch() works with up, down + wnd.keypad(1) + + # display the menu + for i in xrange(len(options)): + text, callback = options[i] + text = text + " " * (width - len(text)) + + # the selected option is displayed in reverse video + if i == selected: + wnd.addstr(i+offy, offx, text, curses.A_REVERSE) + else: + wnd.addstr(i+offy, offx, text) + # display the member + + wnd.refresh() + + # read one keypress + input = wnd.getch() + + # UP moves to the previous option + if input == curses.KEY_UP and selected > 0: + selected = (selected - 1) + + # DOWN moves to the next option + elif input == curses.KEY_DOWN and selected < len(options) - 1: + selected = (selected + 1) + + # RETURN runs the callback for the selected option + elif input == KEY_RETURN: + text, callback = options[selected] + + # highlight the selected option + text = text + " " * (width - len(text)) + wnd.addstr(selected+offy, offx, text, curses.A_REVERSE | curses.A_BOLD) + wnd.refresh() + + # execute the selected option + if callback(wnd): # success + break + + +def reset(): + """Reset the terminal and clear the screen.""" + + reset = curses.tigetstr('rs1') + if not reset: reset = '\x1bc' + curses.putp(reset) + diff --git a/py/csc/apps/legacy/main.py b/py/csc/apps/legacy/main.py new file mode 100644 index 0000000..a55e46c --- /dev/null +++ b/py/csc/apps/legacy/main.py @@ -0,0 +1,555 @@ +# $Id: main.py 44 2006-12-31 07:09:27Z mspang $ +""" +CEO-like Frontend + +This frontend aims to be compatible in both look and function with the +curses UI of CEO. + +Some small improvements have been made, such as not echoing passwords and +aborting when nothing is typed into the first input box during an operation. + +This frontend is poorly documented, deprecated, and undoubtedly full of bugs. +""" +import curses.ascii, re, os +from helpers import menu, inputbox, msgbox, reset +from csc.admin import accounts, members, terms + +# color of the ceo border +BORDER_COLOR = curses.COLOR_RED + + +def action_new_member(wnd): + """Interactively add a new member.""" + + username, studentid, program = '', None, '' + + # read the name + prompt = " Name: " + realname = inputbox(wnd, prompt, 18) + + # abort if no username is entered + if not realname or realname.lower() == 'exit': + return False + + # read the student id + prompt = "Student id:" + while studentid == None or (re.search("[^0-9]", studentid) and not studentid.lower() == 'exit'): + studentid = inputbox(wnd, prompt, 18) + + # abort if exit is entered + if studentid.lower() == 'exit': + return False + + if studentid == '': + studentid = None + + # read the program of study + prompt = " Program:" + program = inputbox(wnd, prompt, 18) + + # abort if exit is entered + if program == None or program.lower() == 'exit': + return False + + # connect the members module to its backend if necessary + if not members.connected(): members.connect() + + # attempt to create the member + try: + memberid = members.new(realname, studentid, program) + + msgbox(wnd, "Success! Your memberid is %s. You are now registered\n" + % memberid + "for the " + terms.current() + " term."); + + except members.InvalidStudentID: + msgbox(wnd, "Invalid student ID.") + return False + except members.DuplicateStudentID: + msgbox(wnd, "A member with this student ID exists.") + return False + + +def action_term_register(wnd): + """Interactively register a member for a term.""" + + memberid, term = '', '' + + # read the member id + prompt = 'Enter memberid ("exit" to cancel):' + memberuserid = inputbox(wnd, prompt, 36) + + if not memberuserid or memberuserid.lower() == 'exit': + return False + + member = get_member_memberid_userid(wnd, memberuserid) + if not member: return False + + memberid = member['memberid'] + term_list = members.terms_list(memberid) + + # display user + display_member_details(wnd, member, term_list) + + # read the term + prompt = "Which term to register for ([fws]20nn):" + while not re.match('^[wsf][0-9]{4}$', term) and not term == 'exit': + term = inputbox(wnd, prompt, 41) + + # abort when exit is entered + if term.lower() == 'exit': + return False + + # already registered? + if members.registered(memberid, term): + msgbox(wnd, "You are already registered for term " + term) + return False + + try: + + # attempt to register + members.register(memberid, term) + + # display success message [sic] + msgbox(wnd, "Your are now registered for term " + term) + + except members.InvalidTerm: + msgbox(wnd, "Term is not valid: %s" % term) + + return False + + +def action_term_register_multiple(wnd): + """Interactively register a member for multiple terms.""" + + memberid, base, num = '', '', None + + # read the member id + prompt = 'Enter memberid ("exit" to cancel):' + memberuserid = inputbox(wnd, prompt, 36) + + if not memberuserid or memberuserid.lower() == 'exit': + return False + + member = get_member_memberid_userid(wnd, memberuserid) + if not member: return False + + memberid = member['memberid'] + term_list = members.terms_list(memberid) + + # display user + display_member_details(wnd, member, term_list) + + # read the base + prompt = "Which term to start registering ([fws]20nn):" + while not re.match('^[wsf][0-9]{4}$', base) and not base == 'exit': + base = inputbox(wnd, prompt, 41) + + # abort when exit is entered + if base.lower() == 'exit': + return False + + # read number of terms + prompt = 'How many terms?' + while not num or not re.match('^[0-9]*$', num): + num = inputbox(wnd, prompt, 36) + num = int(num) + + # any terms in the range? + if num < 1: + msgbox(wnd, "No terms to register.") + return False + + # compile a list to register + term_list = terms.interval(base, num) + + # already registered? + for term in term_list: + if members.registered(memberid, term): + msgbox(wnd, "You are already registered for term " + term) + return False + + try: + + # attempt to register all terms + members.register(memberid, term_list) + + # display success message [sic] + msgbox(wnd, "Your are now registered for terms: " + ", ".join(term_list)) + + except members.InvalidTerm: + msgbox(wnd, "Term is not valid: %s" % term) + + return False + + +def action_create_account(wnd): + """Interactively create an account for a member.""" + + memberid, userid = '', '' + + # read the member id + prompt = 'Enter member ID (exit to cancel):' + memberid = inputbox(wnd, prompt, 35) + + if not memberid or memberid.lower() == 'exit': + return False + + member = get_member_memberid_userid(wnd, memberid) + if not member: return False + + memberid = member['memberid'] + term_list = members.terms_list(memberid) + + # display the member + display_member_details(wnd, member, term_list) + + # verify member + prompt = "Is this the correct member?" + answer = None + while answer != "yes" and answer != "y" and answer != "n" and answer != "no" and answer != "exit": + answer = inputbox(wnd, prompt, 28) + + # user abort + if answer == "exit": + return False + + # incorrect member; abort + if answer == "no" or answer == "n": + msgbox(wnd, "I suggest searching for the member by userid or name from the main menu.") + return False + + # read user id + prompt = "Userid:" + while userid == '': + userid = inputbox(wnd, prompt, 18) + + # user abort + if userid == None or userid.lower() == 'exit': + return False + + # member already has an account? + #if member['userid'] != None: + # msgbox(wnd, "Member " + str(memberid) + " already has an account: " + member['userid'] + "\n" + # "Contact the sysadmin if there are still problems." ) + # return False + + # password input loop + password = "password" + check = "check" + while password != check: + + # read password + prompt = "User password:" + password = None + while not password: + password = inputbox(wnd, prompt, 18, False) + + # read another password + prompt = "Enter the password again:" + check = None + while not check: + check = inputbox(wnd, prompt, 27, False) + + + # create the UNIX account + result = accounts.create_account(userid, password, member['name'], memberid) + + if result == accounts.LDAP_EXISTS: + msgbox(wnd, "Error: Could not do stuff , Already exists.") + return False + elif result == accounts.KRB_EXISTS: + msgbox(wnd, "This account already exists in Kerberos, but not in LDAP. Please contact the Systems Administrator.") + return False + elif result == accounts.LDAP_NO_IDS: + msgbox(wnd, "There are no available UNIX user ids. This is a fatal error. Contact the Systems Administrator.") + return False + elif result == accounts.BAD_REALNAME: + msgbox(wnd, "Invalid real name: %s. Contact the Systems Administrator." % member['name']) + return False + elif result == accounts.BAD_USERNAME: + msgbox(wnd, "Invalid username: %s. Enter a valid username." % userid) + return False + elif result != accounts.SUCCESS: + raise Exception("Unexpected return status of accounts.create_account(): %s" % result) + + # now update the CEO database with the username + members.update( {'memberid':memberid, 'userid': userid} ) + + # success + msgbox(wnd, "Please run 'addhomedir " + userid + "'.") + msgbox(wnd, "Success! Your account has been added") + + +def display_member_details(wnd, member, term_list): + """Display member attributes in a message box.""" + + # clone and sort term_list + term_list = list(term_list) + term_list.sort( terms.compare ) + + # labels for data + id_label, studentid_label, name_label = "ID:", "StudentID:", "Name:" + program_label, userid_label, terms_label = "Program:", "User ID:", "Terms:" + + # format it all into a massive string + message = "%8s %-20s %10s %-10s (user)\n" % (name_label, member['name'], id_label, member['memberid']) + \ + "%8s %-20s %10s %-10s\n" % (program_label, member['program'], studentid_label, member['studentid']) + + if member['userid']: + message += "%8s %s\n" % (userid_label, member['userid']) + else: + message += 'No user ID.\n' + + message += "%s %s" % (terms_label, " ".join(term_list)) + + # display the string in a message box + msgbox(wnd, message) + + +def get_member_memberid_userid(wnd, memberuserid): + """Retrieve member attributes by member of user id.""" + + # connect the members module to its backends + if not members.connected(): members.connect() + + # retrieve member data + + if re.match('^[0-9]*$', memberuserid): + + # numeric memberid, look it up + memberid = int(memberuserid) + member = members.get( memberid ) + if not member: + msgbox(wnd, '%s is an invalid memberid' % memberuserid) + + else: + + # non-numeric memberid: try userids + member = members.get_userid( memberuserid ) + if not member: + msgbox(wnd, "%s is an invalid account userid" % memberuserid) + + return member + + +def action_display_member(wnd): + """Interactively display a member.""" + + # read the member id + prompt = 'Memberid: ' + memberid = inputbox(wnd, prompt, 36) + + if not memberid or memberid.lower() == 'exit': + return False + + member = get_member_memberid_userid(wnd, memberid) + if not member: return + term_list = members.terms_list( member['memberid'] ) + + # display the details in a window + display_member_details(wnd, member, term_list) + + +def page(text): + + try: + pipe = os.popen('/usr/bin/less', 'w') + pipe.write(text) + pipe.close() + except IOError: + # broken pipe (user didn't read the whole list) + pass + + +def format_members(member_list): + """Format a member list into a string.""" + + # clone and sort member_list + member_list = list(member_list) + member_list.sort( lambda x, y: x['memberid']-y['memberid'] ) + + buf = '' + + for member in member_list: + attrs = ( member['memberid'], member['name'], member['studentid'], + member['type'], member['program'], member['userid'] ) + buf += "%4d %50s %10s %10s \n%55s %10s\n\n" % attrs + + return buf + + +def action_list_term(wnd): + """Interactively list members registered in a term.""" + + term = None + + # read the term + prompt = "Which term to list members for ([fws]20nn): " + while term == None or (not term == '' and not re.match('^[wsf][0-9]{4}$', term) and not term == 'exit'): + term = inputbox(wnd, prompt, 41) + + # abort when exit is entered + if not term or term.lower() == 'exit': + return False + + # connect the members module to its backends if necessary + if not members.connected(): members.connect() + + # retrieve a list of members for term + member_list = members.list_term(term) + + # format the data into a mess of text + buf = format_members(member_list) + + # display the mass of text with a pager + page( buf ) + + +def action_list_name(wnd): + + name = None + + # read the name + prompt = "Enter the member's name: " + name = inputbox(wnd, prompt, 41) + + # abort when exit is entered + if not name or name.lower() == 'exit': + return False + + # connect the members module to its backends if necessary + if not members.connected(): members.connect() + + # retrieve a list of members for term + member_list = members.list_name(name) + + # format the data into a mess of text + buf = format_members(member_list) + + # display the mass of text with a pager + page( buf ) + + +def action_list_studentid(wnd): + + studentid = None + + # read the studentid + prompt = "Enter the member's student id: " + studentid = inputbox(wnd, prompt, 41) + + # abort when exit is entered + if not studentid or studentid.lower() == 'exit': + return False + + # connect the members module to its backends if necessary + if not members.connected(): members.connect() + + # retrieve a list of members for term + member = members.get_studentid(studentid) + if member != None: + member_list = [ members.get_studentid(studentid) ] + else: + member_list = [] + + # format the data into a mess of text + buf = format_members(member_list) + + # display the mass of text with a pager + page( buf ) + + +def null_callback(wnd): + """Callback for unimplemented menu options.""" + return False + + +def exit_callback(wnd): + """Callback for the exit option.""" + return True + + +# the top level ceo menu +top_menu = [ + ( "New member", action_new_member ), + ( "Register for a term", action_term_register ), + ( "Register for multiple terms", action_term_register_multiple ), + ( "Display a member", action_display_member ), + ( "List members registered in a term", action_list_term ), + ( "Search for a member by name", action_list_name ), + ( "Search for a member by student id", action_list_studentid ), + ( "Create an account", action_create_account ), + ( "Re Create an account", null_callback ), + ( "Library functions", null_callback ), + ( "Exit", exit_callback ), +] + + +def acquire_ceo_wnd(screen=None): + """Create the top level ceo window.""" + + # hack to get a reference to the entire screen + # even when the caller doesn't (shouldn't) have one + global _screen + if screen == None: + screen = _screen + else: + _screen = screen + + # if the screen changes size, a mess may be left + screen.erase() + + # for some reason, the legacy ceo system + # excluded the top line from its window + height, width = screen.getmaxyx() + ceo_wnd = screen.subwin(height-1, width, 1, 0) + + # draw the border around the ceo window + curses.init_pair(1, BORDER_COLOR, -1) + color_attr = curses.color_pair(1) | curses.A_BOLD + ceo_wnd.attron(color_attr) + ceo_wnd.border() + ceo_wnd.attroff(color_attr) + + # return window and dimensions of inner area + return ceo_wnd, 1, 1, height-2, width-2 + + +def ceo_main_curses(screen): + """Wrapped main for curses.""" + + curses.use_default_colors() + + # workaround for SSH sessions on virtual consoles (reset terminal) + reset() + + # create ceo window + ceo_wnd, menu_y, menu_x, menu_height, menu_width = acquire_ceo_wnd(screen) + + # display the top level menu + menu(ceo_wnd, menu_y, menu_x, menu_width, top_menu, acquire_ceo_wnd) + + +def run(): + """Main function for legacy UI.""" + + # wrap the entire program using curses.wrapper + # so that the terminal is restored to a sane state + # when the program exits + try: + curses.wrapper(ceo_main_curses) + except KeyboardInterrupt: + pass + except curses.error: + print "Your screen is too small!" + raise + except: + reset() + raise + + # clean up screen before exit + reset() + +if __name__ == '__main__': + run() + diff --git a/py/csc/backend/__init__.py b/py/csc/backend/__init__.py new file mode 100644 index 0000000..531a029 --- /dev/null +++ b/py/csc/backend/__init__.py @@ -0,0 +1,12 @@ +# $Id$ +""" +Backends + +This module contains backend interfaces and related modules. +CEO's primary backends are: + + db.py - CEO's database for member and term registrations + ldapi.py - LDAP, for UNIX account metadata administration + krb.py - Kerberos, for UNIX account password administration + +""" diff --git a/py/csc/backend/db.py b/py/csc/backend/db.py new file mode 100644 index 0000000..d01e1a0 --- /dev/null +++ b/py/csc/backend/db.py @@ -0,0 +1,548 @@ +# $Id: db.py 37 2006-12-28 10:00:50Z mspang $ +""" +Database Backend Interface + +This module is intended to be a thin wrapper around CEO database operations. +Methods on the connection class correspond in a straightforward way to SQL +queries. These methods may restructure and clean up query output but may make +no other assumptions about its content or purpose. + +This module makes use of the PygreSQL Python bindings to libpq, +PostgreSQL's native C client library. +""" +import pgdb + + +class DBException(Exception): + """Exception class for database-related errors.""" + pass + + +class DBConnection(object): + """ + Connection to CEO's backend database. All database queries + and updates are made via this class. + + Exceptions: (all methods) + DBException - on database query failure + + Note: Updates will never take place until commit() is called. + + Note: In the event that any method of this class raises a + DBException and that exception is caught, rollback() + must be called before further queries will be successful. + + Example: + connection = DBConnection() + connection.connect("localhost", "ceo") + + # make queries and updates, i.e. + connection.insert_member("Calum T. Dalek") + + connection.commit() + connection.disconnect() + """ + + def __init__(self): + self.cnx = None + self.cursor = None + + + def connect(self, hostname=None, database=None, username=None, password=None): + """ + Establishes the connection to CEO's PostgreSQL database. + + Parameters: + hostname - hostname:port to connect to + database - name of database + username - user to authenticate as + password - password of username + """ + + if self.cnx: raise DBException("unable to connect: already connected") + + try: + self.cnx = pgdb.connect(host=hostname, database=database, + user=username, password=password) + self.cursor = self.cnx.cursor() + except pgdb.Error, e: + raise DBException("unable to connect: %s" % e) + + + def disconnect(self): + """Closes the connection to CEO's PostgreSQL database.""" + + if self.cursor: + self.cursor.close() + self.cursor = None + + if self.cnx: + self.cnx.close() + self.cnx = None + + + def connected(self): + """Determine whether the connection has been established.""" + + return self.cnx != None + + + def commit(self): + """Commits the current transaction and starts a new one.""" + + self.cnx.commit() + + + def rollback(self): + """Aborts the current transaction.""" + + self.cnx.rollback() + + + + ### Member-related methods ### + + def select_members(self, sql, params=None): + """ + Retrieves a list CSC members selected by given SQL statement. + + This is a helper function that should generally not be called directly. + + Parameters: + sql - the SELECT sql statement + params - parameters for the SQL statement + + The sql statement must select the six columns + (memberid, name, studentid, program, type, userid) + from the members table in that order. + + Returns: a memberid-keyed dictionary whose values are + column-keyed dictionaries with member attributes + """ + + # retrieve a list of all members + try: + self.cursor.execute(sql, params) + members_list = self.cursor.fetchall() + except pgdb.Error, e: + raise DBException("SELECT statement failed: %s" % e) + + # build a dictionary of dictionaries from the result (a list of lists) + members_dict = {} + for member in members_list: + memberid, name, studentid, program, type, userid = member + members_dict[memberid] = { + 'memberid': member[0], + 'name': member[1], + 'studentid': member[2], + 'program': member[3], + 'type': member[4], + 'userid': member[5], + } + + return members_dict + + + def select_single_member(self, sql, params=None): + """ + Retrieves a single member by memberid. + + This is a helper function that should generally not be called directly. + + See: self.select_members() + + Returns: a column-keyed dictionary with member attributes, or + None if no member matching member exists + """ + + # retrieve the member + results = self.select_members(sql, params) + + # too many members returned + if len(results) > 1: + raise DBException("multiple members selected: sql='%s' params=%s" % (sql, repr(params))) + + # no such member + elif len(results) < 1: + return None + + # return the single match + memberid = results.keys()[0] + return results[memberid] + + + def select_all_members(self): + """ + Retrieves a list of all CSC members (past and present). + + See: self.select_members() + + Example: connection.select_all_members() -> { + 0: { 'memberid': 0, 'name': 'Calum T. Dalek' ...} + 3349: { 'memberid': 3349, 'name': 'Michael Spang' ...} + ... + } + """ + sql = "SELECT memberid, name, studentid, program, type, userid FROM members" + return self.select_members(sql) + + + def select_members_by_name(self, name_re): + """ + Retrieves a list of all CSC members whose name matches name_re. + + See: self.select_members() + + Example: connection.select_members_by_name('Michael') -> { + 3349: { 'memberid': 3349, 'name': 'Michael Spang' ...} + ... + } + """ + sql = "SELECT memberid, name, studentid, program, type, userid FROM members WHERE name ~* %s" + params = [ str(name_re) ] + + return self.select_members(sql, params) + + + def select_members_by_term(self, term): + """ + Retrieves a list of all CSC members who were members in the specified term. + + See: self.select_members() + + Example: connection.select_members_by_term('f2006') -> { + 3349: { 'memberid': 3349, 'name': 'Michael Spang' ...} + ... + } + """ + sql = "SELECT members.memberid, name, studentid, program, type, userid FROM members JOIN terms ON members.memberid=terms.memberid WHERE term=%s" + params = [ str(term) ] + + return self.select_members(sql, params) + + + def select_member_by_id(self, memberid): + """ + Retrieves a single member by memberid. + + See: self.select_single_member() + + Example: connection.select_member_by_id(0) -> + { 'memberid': 0, 'name': 'Calum T. Dalek' ...} + """ + sql = "SELECT memberid, name, studentid, program, type, userid FROM members WHERE memberid=%d" + params = [ int(memberid) ] + + return self.select_single_member(sql, params) + + + def select_member_by_account(self, username): + """ + Retrieves a single member by UNIX account username. + + See: self.select_single_member() + + Example: connection.select_member_by_account('ctdalek') -> + { 'memberid': 0, 'name': 'Calum T. Dalek' ...} + """ + sql = "SELECT memberid, name, studentid, program, type, userid FROM members WHERE userid=%s" + params = [ username ] + + return self.select_single_member(sql, params) + + + def select_member_by_studentid(self, studentid): + """ + Retrieves a single member by student id number. + + See: self.select_single_member() + + Example: connection.select_member_by_studentid('nnnnnnnn') -> + { 'memberid': 3349, 'name': 'Michael Spang' ...} + """ + sql = "SELECT memberid, name, studentid, program, type, userid FROM members WHERE studentid=%s" + params = [ studentid ] + + return self.select_single_member(sql, params) + + + def insert_member(self, name, studentid=None, program=None): + """ + Creates a member with the specified attributes. + + Parameters: + name - full name of member + studentid - student id number + program - program of study + + Example: connection.insert_member('Michael Spang', '99999999', 'Math/CS') -> 3349 + + Returns: a memberid of created user + """ + try: + # retrieve the next memberid + sql = "SELECT nextval('memberid_seq')" + self.cursor.execute(sql) + result = self.cursor.fetchone() + memberid = result[0] + + # insert the member + sql = "INSERT INTO members (memberid, name, studentid, program, type) VALUES (%d, %s, %s, %s, %s)" + params = [ memberid, name, studentid, program, 'user' ] + self.cursor.execute(sql, params) + + return memberid + except pgdb.Error, e: + raise DBException("failed to create member: %s" % e) + + + def delete_member(self, memberid): + """ + Deletes a member. Note that a member cannot + be deleted until it has been unregistered from + all terms. + + Parameters: + memberid - the member id number to delete + + Example: connection.delete_member(3349) + """ + sql = "DELETE FROM members WHERE memberid=%d" + params = [ memberid ] + + try: + self.cursor.execute(sql, params) + except pgdb.Error, e: + raise DBException("DELETE statement failed: %s" %e) + + + def update_member(self, member): + """ + Modifies member attributes. + + Parameters: + member - a column-keyed dictionary with the new state of the member. + member['memberid'] must be present. ommitted columns + will not be changed. None is NULL. + + Returns: the full new state of the member as a column-keyed dictionary + + Example: connection.update_member({ + 'memberid': 3349, + 'name': 'Michael C. Spang', + 'program': 'CS!' + }) -> { + 'memberid': 3349, + 'name': 'Michael C. Spang', + 'program': CS!', + 'studentid': '99999999' # unchanged + } + + Equivalent Example: + member = connection.select_member_by_id(3349) + member['name'] = 'Michael C. Spang' + member['program'] = 'CS!' + connection.update_member(member) -> { see above } + """ + try: + + # memberid to update + memberid = member['memberid'] + + # retrieve current state of member + member_state = self.select_member_by_id(memberid) + + # build a list of changes to make + changes = [] + for column in member.keys(): + if member[column] != member_state[column]: + + # column's value must be updated + changes.append( (column, member[column]) ) + member_state[column] = member[column] + + # no changes? + if len(changes) < 1: + return member_state + + # make the necessary changes in an update statement + changes = zip(*changes) + sql = "UPDATE members SET " + ", ".join(["%s=%%s"] * len(changes[0])) % changes[0] + " WHERE memberid=%d" + params = changes[1] + ( memberid, ) + self.cursor.execute(sql, params) + + return member_state + except pgdb.Error, e: + raise DBException("member update failed: %s" % e) + + + + ### Term-related methods ### + + def select_term(self, memberid, term): + """ + Determines whether a member is registered for a term. + + Parameters: + memberid - the member id number + term - the term to check + + Returns: a matching term, or None + + Example: connection.select_term(3349, 'f2006') -> 'f2006' + """ + sql = "SELECT term FROM terms WHERE memberid=%d AND term=%s" + params = [ memberid, term ] + + # retrieve matches + try: + self.cursor.execute(sql, params) + result = self.cursor.fetchall() + except pgdb.Error, e: + raise DBException("SELECT statement failed: %s" % e) + + if len(result) > 1: + raise DBException("multiple rows in terms with memberid=%d term=%s" % (memberid, term)) + elif len(result) == 0: + return None + else: + return result[0][0] + + + def select_terms(self, memberid): + """ + Retrieves a list of terms a member is registered for. + + Parameters: + memberid - the member id number + + Returns: a sorted list of terms + + Example: connection.select_terms(3349) -> ['f2006'] + """ + sql = "SELECT term FROM terms WHERE memberid=%d" + params = [ memberid ] + + # retrieve the list of terms + try: + self.cursor.execute(sql, params) + result = self.cursor.fetchall() + except pgdb.Error, e: + raise DBException("SELECT statement failed: %s" % e) + + result = [ row[0] for row in result ] + + return result + + + def insert_term(self, memberid, term): + """ + Registers a member for a term. + + Parameters: + memberid - the member id number to register + term - string representation of the term + + Example: connection.insert_term(3349, 'f2006') + """ + sql = "INSERT INTO terms (memberid, term) VALUES (%d, %s)" + params = [ memberid, term ] + + try: + self.cursor.execute(sql, params) + except pgdb.Error, e: + raise DBException("INSERT statement failed: %s" % e) + + + def delete_term(self, memberid, term): + """ + Unregisters a member for a term. + + Parameters: + memberid - the member id number to register + term - string representation of the term + + Example: connection.delete_term(3349, 'f2006') + """ + sql = "DELETE FROM terms WHERE memberid=%d and term=%s" + params = [ memberid, term ] + + try: + self.cursor.execute(sql, params) + except pgdb.Error, e: + raise DBException("DELETE statement failed: %s" % e) + + + def delete_term_all(self, memberid): + """ + Unregisters a member for all registered terms. + + Parameters: + memberid - the member id number to unregister + + Example: connection.delete_term_all(3349) + """ + sql = "DELETE FROM terms WHERE memberid=%d" + params = [ memberid ] + + # retrieve a list of terms + try: + self.cursor.execute(sql, params) + except pgdb.Error, e: + raise DBException("DELETE statement failed: %s" % e) + + + ### Miscellaneous methods ### + + def trim_memberid_sequence(self): + """ + Sets the value of the member id sequence to the id of the newest + member. For use after extensive testing to prevent large + intervals of unused memberids. + + Note: this does nothing unless the most recently added member(s) have been deleted + """ + self.cursor.execute("SELECT setval('memberid_seq', (SELECT max(memberid) FROM members))") + + + +### Tests ### + +if __name__ == '__main__': + HOST = "localhost" + DATABASE = "ceo" + + connection = DBConnection() + + print "Running disconnect()" + connection.disconnect() + + print "Running connect('%s', '%s')" % (HOST, DATABASE) + connection.connect(HOST, DATABASE) + + print "Running select_all_members()", "->", len(connection.select_all_members()), "members" + print "Running select_member_by_id(0)", "->", connection.select_member_by_id(0)['userid'] + print "Running select_members_by_name('Spang')", "->", connection.select_members_by_name('Spang').keys() + print "Running select_members_by_term('f2006')", "->", "[" + ", ".join(map(str, connection.select_members_by_term('f2006').keys()[0:10])) + " ...]" + + print "Running insert_member('test_member', '99999999', 'program')", + memberid = connection.insert_member('test_member', '99999999', 'program') + print "->", memberid + + print "Running select_member_by_id(%d)" % memberid, "->", connection.select_member_by_id(memberid) + print "Running insert_term(%d, 'f2006')" % memberid + connection.insert_term(memberid, 'f2006') + + print "Running select_terms(%d)" % memberid, "->", connection.select_terms(memberid) + print "Running update_member({'memberid':%d,'name':'test_updated','studentid':-1})" % memberid + connection.update_member({'memberid':memberid,'name':'test_updated','studentid':99999999}) + print "Running select_member_by_id(%d)" % memberid, "->", connection.select_member_by_id(memberid) + + print "Running rollback()" + connection.rollback() + + print "Resetting memberid sequence" + connection.trim_memberid_sequence() + + print "Running disconnect()" + connection.disconnect() diff --git a/py/csc/backend/ipc.py b/py/csc/backend/ipc.py new file mode 100644 index 0000000..8348a57 --- /dev/null +++ b/py/csc/backend/ipc.py @@ -0,0 +1,222 @@ +# $Id: ipc.py 26 2006-12-20 21:25:08Z mspang $ +""" +IPC Library Functions + +This module contains very UNIX-specific code to allow interactive +communication with another program. For CEO they are required to +talk to kadmin because there is no Kerberos administration Python +module. Real bindings to libkadm5 are doable and thus a TODO. +""" +import os, pty, select + + +class _pty_file(object): + """ + A 'file'-like wrapper class for pseudoterminal file descriptors. + + This wrapper is necessary because Python has a nasty + habit of throwing OSError at pty EOF. + + This class also implements timeouts for read operations + which are handy for avoiding deadlock when both + processes are blocked in a read(). + + See the Python documentation of the file class + for explanation of the methods. + """ + def __init__(self, fd): + self.fd = fd + self.buffer = '' + self.closed = False + def __repr__(self): + status='open' + if self.closed: + status = 'closed' + return "<" + status + " pty '" + os.ttyname(self.fd) + "'>" + def read(self, size=-1, block=True, timeout=0.1): + if self.closed: raise ValueError + if size < 0: + data = None + + # read data, catching OSError as EOF + try: + while data != '': + + # wait timeout for the pty to become ready, otherwise stop reading + if not block and len(select.select([self.fd],[],[], timeout)[0]) == 0: + break + + data = os.read(self.fd, 65536) + self.buffer += data + except OSError: + pass + + data = self.buffer + self.buffer = '' + return data + else: + if len(self.buffer) < size: + + # read data, catching OSError as EOF + try: + + # wait timeout for the pty to become ready, then read + if block or len(select.select([self.fd],[],[], timeout)[0]) != 0: + self.buffer += os.read(self.fd, size - len(self.buffer) ) + + except OSError: + pass + + data = self.buffer[:size] + self.buffer = self.buffer[size:] + return data + def readline(self, size=-1, block=True, timeout=0.1): + data = None + + # read data, catching OSError as EOF + try: + while data != '' and self.buffer.find("\n") == -1 and (size < 0 or len(self.buffer) < size): + + # wait timeout for the pty to become ready, otherwise stop reading + if not block and len(select.select([self.fd],[],[], timeout)[0]) == 0: + break + + data = os.read(self.fd, 128) + self.buffer += data + except OSError: + pass + + split_index = self.buffer.find("\n") + 1 + if split_index < 0: + split_index = len(self.buffer) + if size >= 0 and split_index > size: + split_index = size + line = self.buffer[:split_index] + self.buffer = self.buffer[split_index:] + return line + def readlines(self, sizehint=None, block=True, timeout=0.1): + lines = [] + line = None + while True: + line = self.readline(-1, False, timeout) + if line == '': break + lines.append(line) + return lines + def write(self, data): + if self.closed: raise ValueError + os.write(self.fd, data) + def writelines(self, lines): + for line in lines: + self.write(line) + def __iter__(self): + return self + def next(self): + line = self.readline() + if line == '': + raise StopIteration + return line + def isatty(self): + if self.closed: raise ValueError + return os.isatty(self.fd) + def fileno(self): + if self.closed: raise ValueError + return self.fd + def flush(self): + if self.closed: raise ValueError + os.fsync(self.fd) + def close(self): + if not self.closed: os.close(self.fd) + self.closed = True + + +def popeni(command, args, env=None): + """ + Open an interactive session with another command. + + Parameters: + command - the command to run (full path) + args - a list of arguments to pass to command + env - optional environment for command + + Returns: (pid, stdout, stdIn) + """ + + # use a pipe to send data to the child + child_stdin, parent_stdin = os.pipe() + + # a pipe for receiving data would cause buffering and + # is therefore not suitable for interactive communication + # i.e. parent_stdout, child_stdout = os.pipe() + + # therefore a pty must be used instead + master, slave = pty.openpty() + + # collect both stdout and stderr on the pty + parent_stdout, child_stdout = master, slave + parent_stderr, child_stderr = master, slave + + # fork the child to communicate with + pid = os.fork() + + # child process + if pid == 0: + + # close all of the parent's fds + os.close(parent_stdin) + if parent_stdout != parent_stdin: + os.close(parent_stdout) + if parent_stderr != parent_stdin and parent_stderr != parent_stdout: + os.close(parent_stderr) + + # if stdout is a terminal, set it to the controlling terminal + if os.isatty(child_stdout): + + # determine the filename of the tty + tty = os.ttyname(child_stdout) + + # create a new session to disconnect + # from the parent's controlling terminal + os.setsid() + + # set the controlling terminal to the pty + # by opening it (and closing it again since + # it's already open as child_stdout) + fd = os.open(tty, os.O_RDWR); + os.close(fd) + + # init stdin/out/err + os.dup2(child_stdin, 0) + os.dup2(child_stdout, 1) + if child_stderr >= 0: + os.dup2(child_stderr, 2) + + # finally, execute the child + if env: + os.execv(command, args, env) + else: + os.execv(command, args) + + # parent process + else: + + # close all of the child's fds + os.close(child_stdin) + if child_stdout != child_stdin: + os.close(child_stdout) + if child_stderr >= 0 and child_stderr != child_stdin and child_stderr != child_stdout: + os.close(child_stderr) + + return pid, _pty_file(parent_stdout), os.fdopen(parent_stdin, 'w') + + +### Tests ### + +if __name__ == '__main__': + + import sys + pid, recv, send = popeni('/usr/sbin/kadmin.local', ['kadmin']) + + send.write("listprincs\n") + send.flush() + + print recv.readlines() diff --git a/py/csc/backend/krb.py b/py/csc/backend/krb.py new file mode 100644 index 0000000..23d021b --- /dev/null +++ b/py/csc/backend/krb.py @@ -0,0 +1,448 @@ +# $Id: krb.py 40 2006-12-29 00:40:31Z mspang $ +""" +Kerberos Backend Interface + +This module is intended to be a thin wrapper around Kerberos operations. +Methods on the connection object correspond in a straightforward way to +calls to the Kerberos Master server. + +A Kerberos principal is the second half of a CSC UNIX account. The principal +stores the user's password and and is used for all authentication on CSC +systems. Accounts that do not authenticate (e.g. club accounts) do not need +a Kerberos principal. + +Unfortunately, there are no Python bindings to libkadm at this time. As a +temporary workaround, This module communicates with the kadmin CLI interface +via a pseudoterminal and pipe. +""" +import os +import ipc + + +class KrbException(Exception): + """Exception class for all Kerberos-related errors.""" + pass + + +class KrbConnection(object): + """ + Connection to the Kerberos master server (kadmind). All Kerberos + principal updates are made via this class. + + Exceptions: (all methods) + KrbException - on query/update failure + + Example: + connection = KrbConnection() + connection.connect(...) + + # make queries and updates, e.g. + connection.delete_principal("mspang") + + connection.disconnect() + """ + + def __init__(self): + self.pid = None + + + def connect(self, principal, keytab): + """ + Establishes the connection to the Kerberos master server. + + Parameters: + principal - the Kerberos princiapl to authenticate as + keytab - keytab filename for authentication + + Example: connection.connect('ceo/admin@CSCLUB.UWATERLOO.CA', '/etc/ceo.keytab') + """ + + # check keytab + if not os.access(keytab, os.R_OK): + raise KrbException("cannot access Kerberos keytab: %s" % keytab) + + # command to run + kadmin = '/usr/sbin/kadmin' + kadmin_args = ['kadmin', '-p', principal, '-kt', keytab] + + # fork the kadmin command + self.pid, self.kadm_out, self.kadm_in = ipc.popeni(kadmin, kadmin_args) + + # read welcome messages + welcome = self.read_result() + + # sanity checks on welcome messages + for line in welcome: + + # ignore auth message + if line.find("Authenticating") == 0: + continue + + # ignore log file message + elif line.find("kadmin.log") != -1: + continue + + # error message? + else: + raise KrbException("unexpected kadmin output: " + welcome[0]) + + + def disconnect(self): + """Close the connection to the master server.""" + + if self.pid: + + # close the pipe connected to kadmin's standard input + self.kadm_in.close() + + # close the master pty connected to kadmin's stdout + try: + self.kadm_out.close() + except OSError: + pass + + # wait for kadmin to terminate + os.waitpid(self.pid, 0) + self.pid = None + + + def connected(self): + """Determine whether the connection has been established.""" + + return self.pid != None + + + + ### Helper Methods ### + + def read_result(self): + """ + Helper function to read output of kadmin until it + prompts for input. + + Returns: a list of lines returned by kadmin + """ + + # list of lines output by kadmin + result = [] + + # the kadmin prompt that signals the end output + # note: KADMIN_ARGS[0] must be "kadmin" or the actual prompt will differ + prompt = "kadmin:" + + # timeout variables. the timeout will start at timeout and + # increase up to max_timeout when read() returns nothing (i.e., times out) + timeout = 0.01 + timeout_increment = 0.10 + timeout_maximum = 1.00 + + # input loop: read from kadmin until the kadmin prompt + buffer = '' + while True: + + # attempt to read any available data + data = self.kadm_out.read(block=False, timeout=timeout) + buffer += data + + # nothing was read + if data == '': + + # so wait longer for data next time + if timeout < timeout_maximum: + timeout += timeout_increment + continue + + # give up after too much waiting + else: + + # check kadmin status + status = os.waitpid(self.pid, os.WNOHANG) + if status[0] == 0: + + # kadmin still alive + raise KrbException("timeout while reading response from kadmin") + + else: + + # kadmin died! + raise KrbException("kadmin died while reading response") + + # break into lines and save all but the final + # line (which is incomplete) into result + lines = buffer.split("\n") + buffer = lines[-1] + lines = lines[:-1] + for line in lines: + line = line.strip() + result.append(line) + + # if the incomplete lines in the buffer is the kadmin prompt, + # then the result is complete and may be returned + if buffer.strip() == prompt: + break + + return result + + + def execute(self, command): + """ + Helper function to execute a kadmin command. + + Parameters: + command - the command to execute + + Returns: a list of lines output by the command + """ + + # there should be no remaining output from the previous + # command. if there is then something is broken. + stale_output = self.kadm_out.read(block=False, timeout=0) + if stale_output != '': + raise KrbException("unexpected kadmin output: " + stale_output) + + # send the command to kadmin + self.kadm_in.write(command + "\n") + self.kadm_in.flush() + + # read the command output and return it + result = self.read_result() + return result + + + + ### Commands ### + + def list_principals(self): + """ + Retrieve a list of Kerberos principals. + + Returns: a list of principals + + Example: connection.list_principals() -> [ + "ceo/admin@CSCLUB.UWATERLOO.CA", + "sysadmin/admin@CSCLUB.UWATERLOO.CA", + "mspang@CSCLUB.UWATERLOO.CA", + ] + + """ + + principals = self.execute("list_principals") + + # assuming that there at least some host principals + if len(principals) < 1: + raise KrbException("no kerberos principals") + + # detect error message + if principals[0].find("kadmin:") == 0: + raise KrbException("list_principals returned error: " + principals[0]) + + # verify principals are well-formed + for principal in principals: + if principal.find("@") == -1: + raise KrbException('malformed pricipal: "' + principal + '"') + + return principals + + + def get_principal(self, principal): + """ + Retrieve principal details. + + Returns: a dictionary of principal attributes + + Example: connection.get_principal("ceo/admin@CSCLUB.UWATERLOO.CA") -> { + "Principal": "ceo/admin@CSCLUB.UWATERLOO.CA", + "Policy": "[none]", + ... + } + """ + + output = self.execute('get_principal "' + principal + '"') + + # detect error message + if output[0].find("kadmin:") == 0: + raise KrbException("get_principal returned error: " + output[0]) + + # detect more errors + if output[0].find("get_principal: ") == 0: + + message = output[0][15:] + + # principal does not exist => None + if message.find("Principal does not exist") == 0: + return None + + # dictionary to store attributes + principal_attributes = {} + + # attributes that will not be returned + ignore_attributes = ['Key'] + + # split output into a dictionary of attributes + for line in output: + key, value = line.split(":", 1) + value = value.strip() + if not key in ignore_attributes: + principal_attributes[key] = value + + return principal_attributes + + + def get_privs(self): + """ + Retrieve privileges of the current principal. + + Returns: a list of privileges + + Example: connection.get_privs() -> + [ "GET", "ADD", "MODIFY", "DELETE" ] + """ + + output = self.execute("get_privs") + + # one line of output is expected + if len(output) > 1: + raise KrbException("unexpected output of get_privs: " + output[1]) + + # detect error message + if output[0].find("kadmin:") == 0: + raise KrbException("get_privs returned error: " + output[0]) + + # parse output by removing the prefix and splitting it around spaces + if output[0][:20] != "current privileges: ": + raise KrbException("malformed get_privs output: " + output[0]) + privs = output[0][20:].split(" ") + + return privs + + + def add_principal(self, principal, password): + """ + Create a new principal. + + Parameters: + principal - the name of the principal + password - the principal's initial password + + Example: connection.add_principal("mspang@CSCLUB.UWATERLOO.CA", "opensesame") + """ + + # exec the add_principal command + if password.find('"') == -1: + self.kadm_in.write('add_principal -pw "' + password + '" "' + principal + '"\n') + + # fools at MIT didn't bother implementing escaping, so passwords + # that contain double quotes must be treated specially + else: + self.kadm_in.write('add_principal "' + principal + '"\n') + self.kadm_in.write(password + "\n" + password + "\n") + + # send request and read response + self.kadm_in.flush() + output = self.read_result() + + # verify output + created = False + for line in output: + + # ignore NOTICE lines + if line.find("NOTICE:") == 0: + continue + + # ignore prompts + elif line.find("Enter password") == 0 or line.find("Re-enter password") == 0: + continue + + # record whether success message was encountered + elif line.find("Principal") == 0 and line.find("created.") != 0: + created = True + + # error messages + elif line.find("add_principal:") == 0 or line.find("kadmin:") == 0: + + # principal exists + if line.find("already exists") != -1: + raise KrbException("principal already exists") + + # misc errors + else: + raise KrbException(line) + + # unknown output + else: + raise KrbException("unexpected add_principal output: " + line) + + # ensure success message was received + if not created: + raise KrbException("did not receive principal created in response") + + + def delete_principal(self, principal): + """ + Delete a principal. + + Parameters: + principal - the principal name + + Example: connection.delete_principal("mspang@CSCLUB.UWATERLOO.CA") + """ + + # exec the delete_principal command and read response + self.kadm_in.write('delete_principal -force "' + principal + '"\n') + self.kadm_in.flush() + output = self.read_result() + + # verify output + deleted = False + for line in output: + + # ignore reminder + if line.find("Make sure that") == 0: + continue + + # record whether success message was encountered + elif line.find("Principal") == 0 and line.find("deleted.") != -1: + deleted = True + + # error messages + elif line.find("delete_principal:") == 0 or line.find("kadmin:") == 0: + + # principal exists + if line.find("does not exist") != -1: + raise KrbException("principal does not exist") + + # misc errors + else: + raise KrbException(line) + + # unknown output + else: + raise KrbException("unexpected delete_principal output: " + line) + + # ensure success message was received + if not deleted: + raise KrbException("did not receive principal deleted") + + + +### Tests ### + +if __name__ == '__main__': + PRINCIPAL = 'ceo/admin@CSCLUB.UWATERLOO.CA' + KEYTAB = 'ceo.keytab' + + connection = KrbConnection() + print "running disconnect()" + connection.disconnect() + print "running connect('%s', '%s')" % (PRINCIPAL, KEYTAB) + connection.connect(PRINCIPAL, KEYTAB) + print "running list_principals()", "->", "[" + ", ".join(map(repr,connection.list_principals()[0:3])) + " ...]" + print "running get_privs()", "->", str(connection.get_privs()) + print "running add_principal('testtest', 'BLAH')" + connection.add_principal("testtest", "FJDSLDLFKJSF") + print "running get_principal('testtest')", "->", '(' + connection.get_principal("testtest")['Principal'] + ')' + print "running delete_principal('testtest')" + connection.delete_principal("testtest") + print "running disconnect()" + connection.disconnect() + diff --git a/py/csc/backend/ldapi.py b/py/csc/backend/ldapi.py new file mode 100644 index 0000000..cf32e8f --- /dev/null +++ b/py/csc/backend/ldapi.py @@ -0,0 +1,577 @@ +# $Id: ldapi.py 41 2006-12-29 04:22:31Z mspang $ +""" +LDAP Backend Interface + +This module is intended to be a thin wrapper around LDAP operations. +Methods on the connection object correspond in a straightforward way +to LDAP queries and updates. + +A LDAP entry is the most important component of a CSC UNIX account. +The entry contains the username, user id number, real name, shell, +and other important information. All non-local UNIX accounts must +have an LDAP entry, even if the account does not log in directly. + +This module makes use of python-ldap, a Python module with bindings +to libldap, OpenLDAP's native C client library. +""" +import ldap.modlist + + +class LDAPException(Exception): + """Exception class for LDAP-related errors.""" + + +class LDAPConnection(object): + """ + Connection to the LDAP directory. All directory + queries and updates are made via this class. + + Exceptions: (all methods) + LDAPException - on directory query failure + + Example: + connection = LDAPConnection() + connection.connect(...) + + # make queries and updates, e.g. + connection.user_delete('mspang') + + connection.disconnect() + """ + + def __init__(self): + self.ldap = None + + + def connect(self, server, bind_dn, bind_pw, user_base, group_base): + """ + Establish a connection to the LDAP Server. + + Parameters: + server - connection string (e.g. ldap://foo.com, ldaps://bar.com) + bind_dn - distinguished name to bind to + bind_pw - password of bind_dn + user_base - base of the users subtree + group_base - baes of the group subtree + + Example: connect('ldaps:///', 'cn=ceo,dc=csclub,dc=uwaterloo,dc=ca', + 'secret', 'ou=People,dc=csclub,dc=uwaterloo,dc=ca', + 'ou=Group,dc=csclub,dc=uwaterloo,dc=ca') + + """ + + if bind_pw == None: bind_pw = '' + + try: + + # open the connection + self.ldap = ldap.initialize(server) + + # authenticate as ceo + self.ldap.simple_bind_s(bind_dn, bind_pw) + + except ldap.LDAPError, e: + raise LDAPException("unable to connect: %s" % e) + + self.user_base = user_base + self.group_base = group_base + + + def disconnect(self): + """Close the connection to the LDAP server.""" + + if self.ldap: + + # close connection + try: + self.ldap.unbind_s() + self.ldap = None + except ldap.LDAPError, e: + raise LDAPException("unable to disconnect: %s" % e) + + + def connected(self): + """Determine whether the connection has been established.""" + + return self.ldap != None + + + + ### Helper Methods ### + + def lookup(self, dn): + """ + Helper method to retrieve the attributes of an entry. + + Parameters: + dn - the distinguished name of the directory entry + + Returns: a dictionary of attributes of the matched dn, or + None of the dn does not exist in the directory + """ + + # search for the specified dn + try: + matches = self.ldap.search_s(dn, ldap.SCOPE_BASE) + except ldap.NO_SUCH_OBJECT: + return None + except ldap.LDAPError, e: + raise LDAPException("unable to lookup dn %s: %s" % (dn, e)) + + # this should never happen due to the nature of DNs + if len(matches) > 1: + raise LDAPException("duplicate dn in ldap: " + dn) + + # return the attributes of the single successful match + else: + match = matches[0] + match_dn, match_attributes = match + return match_attributes + + + + ### User-related Methods ### + + def user_lookup(self, uid): + """ + Retrieve the attributes of a user. + + Parameters: + uid - the UNIX user accound name of the user + + Returns: attributes of user with uid + + Example: connection.user_lookup('mspang') -> + { 'uid': 'mspang', 'uidNumber': 21292 ...} + """ + + dn = 'uid=' + uid + ',' + self.user_base + return self.lookup(dn) + + + def user_search(self, filter): + """ + Helper for user searches. + + Parameters: + filter - LDAP filter string to match users against + + Returns: the list of uids matched + """ + + # search for entries that match the filter + try: + matches = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, filter) + except ldap.LDAPError, e: + raise LDAPException("user search failed: %s" % e) + + # list for uids found + uids = [] + + for match in matches: + dn, attributes = match + + # uid is a required attribute of posixAccount + if not attributes.has_key('uid'): + raise LDAPException(dn + ' (posixAccount) has no uid') + + # do not handle the case of multiple usernames in one entry (yet) + elif len(attributes['uid']) > 1: + raise LDAPException(dn + ' (posixAccount) has multiple uids') + + # append the sole uid of this match to the list + uids.append( attributes['uid'][0] ) + + return uids + + + def user_search_id(self, uidNumber): + """ + Retrieves a list of users with a certain UNIX uid number. + + LDAP (or passwd for that matter) does not enforce any + restriction on the number of accounts that can have + a certain UID. Therefore this method returns a list of matches. + + Parameters: + uidNumber - the user id of the accounts desired + + Returns: the list of uids matched + + Example: connection.user_search_id(21292) -> ['mspang'] + """ + + # search for posixAccount entries with the specified uidNumber + filter = '(&(objectClass=posixAccount)(uidNumber=%d))' % uidNumber + return self.user_search(filter) + + + def user_search_gid(self, gidNumber): + """ + Retrieves a list of users with a certain UNIX gid number. + + Parameters: + gidNumber - the group id of the accounts desired + + Returns: the list of uids matched + """ + + # search for posixAccount entries with the specified gidNumber + filter = '(&(objectClass=posixAccount)(gidNumber=%d))' % gidNumber + return self.user_search(filter) + + + def user_add(self, uid, cn, loginShell, uidNumber, gidNumber, homeDirectory, gecos): + """ + Adds a user to the directory. + + Parameters: + uid - the UNIX username for the account + cn - the full name of the member + userPassword - password of the account (our setup does not use this) + loginShell - login shell for the user + uidNumber - the UNIX user id number + gidNumber - the UNIX group id number + homeDirectory - home directory for the user + gecos - comment field (usually stores miscellania) + + Example: connection.user_add('mspang', 'Michael Spang', + '/bin/bash', 21292, 100, '/users/mspang', + 'Michael Spang,,,') + """ + + dn = 'uid=' + uid + ',' + self.user_base + attrs = { + 'objectClass': [ 'top', 'account', 'posixAccount', 'shadowAccount' ], + 'uid': [ uid ], + 'cn': [ cn ], + 'loginShell': [ loginShell ], + 'uidNumber': [ str(uidNumber) ], + 'gidNumber': [ str(gidNumber) ], + 'homeDirectory': [ homeDirectory ], + 'gecos': [ gecos ], + } + + try: + modlist = ldap.modlist.addModlist(attrs) + self.ldap.add_s(dn, modlist) + except ldap.LDAPError, e: + raise LDAPException("unable to add: %s" % e) + + + def user_modify(self, uid, attrs): + """ + Update user attributes in the directory. + + Parameters: + uid - username of the user to modify + entry - dictionary as returned by user_lookup() with changes to make. + omitted attributes are DELETED. + + Example: user = user_lookup('mspang') + user['uidNumber'] = [ '0' ] + connection.user_modify('mspang', user) + """ + + # distinguished name of the entry to modify + dn = 'uid=' + uid + ',' + self.user_base + + # retrieve current state of user + old_user = self.user_lookup(uid) + + try: + + # build list of modifications to make + changes = ldap.modlist.modifyModlist(old_user, attrs) + + # apply changes + self.ldap.modify_s(dn, changes) + + except ldap.LDAPError, e: + raise LDAPException("unable to modify: %s" % e) + + + def user_delete(self, uid): + """ + Removes a user from the directory. + + Parameters: + uid - the UNIX username of the account + + Example: connection.user_delete('mspang') + """ + + try: + dn = 'uid=' + uid + ',' + self.user_base + self.ldap.delete_s(dn) + except ldap.LDAPError, e: + raise LDAPException("unable to delete: %s" % e) + + + + ### Group-related Methods ### + + def group_lookup(self, cn): + """ + Retrieves the attributes of a group. + + Parameters: + cn - the UNIX group name to lookup + + Returns: attributes of group with cn + + Example: connection.group_lookup('office') -> { + 'cn': 'office', + 'gidNumber', '1001', + ... + } + """ + + dn = 'cn=' + cn + ',' + self.group_base + return self.lookup(dn) + + + def group_search_id(self, gidNumber): + """ + Retrieves a list of groups with the specified UNIX group number. + + Parameters: + gidNumber - the group id of the groups desired + + Returns: a list of groups with gid gidNumber + + Example: connection.group_search_id(1001) -> ['office'] + """ + + # search for posixAccount entries with the specified uidNumber + try: + filter = '(&(objectClass=posixGroup)(gidNumber=%d))' % gidNumber + matches = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, filter) + except ldap.LDAPError,e : + raise LDAPException("group search failed: %s" % e) + + # list for groups found + group_cns = [] + + for match in matches: + dn, attributes = match + + # cn is a required attribute of posixGroup + if not attributes.has_key('cn'): + raise LDAPException(dn + ' (posixGroup) has no cn') + + # do not handle the case of multiple cns for one group (yet) + elif len(attributes['cn']) > 1: + raise LDAPException(dn + ' (posixGroup) has multiple cns') + + # append the sole uid of this match to the list + group_cns.append( attributes['cn'][0] ) + + return group_cns + + + def group_add(self, cn, gidNumber): + """ + Adds a group to the directory. + + Parameters: + cn - the name of the group + gidNumber - the number of the group + + Example: connection.group_add('office', 1001) + """ + + dn = 'cn=' + cn + ',' + self.group_base + attrs = { + 'objectClass': [ 'top', 'posixGroup' ], + 'cn': [ cn ], + 'gidNumber': [ str(gidNumber) ], + } + + try: + modlist = ldap.modlist.addModlist(attrs) + self.ldap.add_s(dn, modlist) + except ldap.LDAPError, e: + raise LDAPException("unable to add group: %s" % e) + + + def group_modify(self, cn, attrs): + """ + Update group attributes in the directory. + + The only available updates are fairly destructive + (rename or renumber) but this method is provided + for completeness. + + Parameters: + cn - name of the group to modify + entry - dictionary as returned by group_lookup() with changes to make. + omitted attributes are DELETED. + + Example: group = group_lookup('office') + group['gidNumber'] = [ str(connection.first_id(20000, 40000)) ] + del group['memberUid'] + connection.group_modify('office', group) + """ + + # distinguished name of the entry to modify + dn = 'cn=' + cn + ',' + self.group_base + + # retrieve current state of group + old_group = self.group_lookup(cn) + + try: + + # build list of modifications to make + changes = ldap.modlist.modifyModlist(old_group, attrs) + + # apply changes + self.ldap.modify_s(dn, changes) + + except ldap.LDAPError, e: + raise LDAPException("unable to modify: %s" % e) + + + def group_delete(self, cn): + """ + Removes a group from the directory." + + Parameters: + cn - the name of the group + + Example: connection.group_delete('office') + """ + + try: + dn = 'cn=' + cn + ',' + self.group_base + self.ldap.delete_s(dn) + except ldap.LDAPError, e: + raise LDAPException("unable to delete group: %s" % e) + + + def group_members(self, cn): + """ + Retrieves a group's members. + + Parameters: + cn - the name of the group + + Example: connection.group_members('office') -> + ['sfflaw', 'jeperry', 'cschopf' ...] + """ + + group = self.group_lookup(cn) + return group.get('memberUid', None) + + + ### Miscellaneous Methods ### + + def first_id(self, minimum, maximum): + """ + Determines the first available id within a range. + + To be "available", there must be neither a user + with the id nor a group with the id. + + Parameters: + minimum - smallest uid that may be returned + maximum - largest uid that may be returned + + Returns: the id, or None if there are none available + + Example: connection.first_id(20000, 40000) -> 20018 + """ + + # compile a list of used uids + try: + users = self.ldap.search_s(self.user_base, ldap.SCOPE_SUBTREE, '(objectClass=posixAccount)', ['uidNumber']) + except ldap.LDAPError, e: + raise LDAPException("search for uids failed: %s" % e) + uids = [] + for user in users: + dn, attrs = user + uid = int(attrs['uidNumber'][0]) + if minimum <= uid <= maximum: + uids.append(uid) + + # compile a list of used gids + try: + groups = self.ldap.search_s(self.group_base, ldap.SCOPE_SUBTREE, '(objectClass=posixGroup)', ['gidNumber']) + except ldap.LDAPError, e: + raise LDAPException("search for gids failed: %s" % e) + gids = [] + for group in groups: + dn, attrs = group + gid = int(attrs['gidNumber'][0]) + if minimum <= gid <= maximum: + gids.append(gid) + + # iterate through ids and return the first available + for id in xrange(minimum, maximum+1): + if not id in uids and not id in gids: + return id + + # no suitable id was found + return None + + +### Tests ### + +if __name__ == '__main__': + + password_file = 'ldap.ceo' + server = 'ldaps:///' + base_dn = 'dc=csclub,dc=uwaterloo,dc=ca' + bind_dn = 'cn=ceo,' + base_dn + user_dn = 'ou=People,' + base_dn + group_dn = 'ou=Group,' + base_dn + bind_pw = open(password_file).readline().strip() + + connection = LDAPConnection() + print "running disconnect()" + connection.disconnect() + print "running connect('%s', '%s', '%s', '%s', '%s')" % (server, bind_dn, '***', user_dn, group_dn) + connection.connect(server, bind_dn, bind_pw, user_dn, group_dn) + print "running user_lookup('mspang')", "->", "(%s)" % connection.user_lookup('mspang')['uidNumber'][0] + print "running user_search_id(21292)", "->", connection.user_search_id(21292) + print "running first_id(20000, 40000)", "->", + first_id = connection.first_id(20000, 40000) + print first_id + print "running group_add('testgroup', %d)" % first_id + try: + connection.group_add('testgroup', first_id) + except Exception, e: + print "FAILED: %s (continuing)" % e + print "running user_add('testuser', 'Test User', '/bin/false', %d, %d, '/home/null', 'Test User,,,')" % (first_id, first_id) + try: + connection.user_add('testuser', 'Test User', '/bin/false', first_id, first_id, '/home/null', 'Test User,,,') + except Exception, e: + print "FAILED: %s (continuing)" % e + print "running user_lookup('testuser')", "->", + user = connection.user_lookup('testuser') + print repr(connection.user_lookup('testuser')['cn'][0]) + user['homeDirectory'] = ['/home/changed'] + user['loginShell'] = ['/bin/true'] + print "running user_modify(...)" + connection.user_modify('testuser', user) + print "running user_lookup('testuser')", "->", + user = connection.user_lookup('testuser') + print '(%s, %s)' % (user['homeDirectory'], user['loginShell']) + print "running group_lookup('testgroup')", "->", + group = connection.group_lookup('testgroup') + print group + print "running group_modify(...)" + group['gidNumber'] = [str(connection.first_id(20000, 40000))] + group['memberUid'] = [ str(first_id) ] + connection.group_modify('testgroup', group) + print "running group_lookup('testgroup')", "->", + group = connection.group_lookup('testgroup') + print group + print "running user_delete('testuser')" + connection.user_delete('testuser') + print "running group_delete('testgroup')" + connection.group_delete('testgroup') + print "running user_search_gid(100)", "->", "[" + ", ".join(map(repr,connection.user_search_gid(100)[:10])) + " ...]" + print "running group_members('office')", "->", "[" + ", ".join(map(repr,connection.group_members('office')[:10])) + " ...]" + print "running disconnect()" + connection.disconnect() diff --git a/py/csc/lib/__init__.py b/py/csc/lib/__init__.py new file mode 100644 index 0000000..2902f1b --- /dev/null +++ b/py/csc/lib/__init__.py @@ -0,0 +1,46 @@ +"""Library Routines""" + +def read_config(config_file): + + try: + conffile = open(config_file) + except IOError: + return None + + options = {} + + while True: + + line = conffile.readline() + if line == '': + break + + if '#' in line: + line = line[:line.find('#')] + + while len(line) > 1 and line[-2] == '\\': + line = line[:-2] + line[-1] + next = conffile.readline() + line += next + if next == '': + break + + pair = map(str.strip, line.split('=', 1)) + + if len(pair) == 2: + key, val = pair + + if val[0] == val[-1] == '"': + val = val[1:-1] + else: + try: + val = int(val) + except: + pass + + options[key] = val + elif len(pair[0]) > 1: + key, = pair + options[key] = None + + return options diff --git a/sql/initialize.sh b/sql/initialize.sh new file mode 100755 index 0000000..fe285a6 --- /dev/null +++ b/sql/initialize.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# $Id: initialize.sh 13 2006-12-15 03:57:00Z mspang $ +# Initializes a database for CEO. + +# initialize the database +createdb $1 +createlang plpythonu $1 + +# initialize the tables +psql $1 < structure.sql + +# initialize check triggers +psql $1 < verify_studentid.sql +psql $1 < verify_term.sql diff --git a/sql/structure.sql b/sql/structure.sql new file mode 100644 index 0000000..a37258f --- /dev/null +++ b/sql/structure.sql @@ -0,0 +1,25 @@ +-- $Id: structure.sql 36 2006-12-28 10:00:11Z mspang $ +-- Table structure for CEO's SQL database. + +-- Usage: +-- su postgres +-- createdb ceo +-- psql ceo < structure.sql + +CREATE SEQUENCE memberid_seq; + +CREATE TABLE members ( + memberid integer PRIMARY KEY DEFAULT nextval('memberid_seq') NOT NULL, + name character varying(50) NOT NULL, + studentid character varying(10) UNIQUE, + program character varying(50), + "type" character varying(10), + userid character varying(32) UNIQUE +); + + +CREATE TABLE terms ( + memberid integer NOT NULL, + term character(5) NOT NULL, + UNIQUE(memberid, term) +); diff --git a/sql/verify_studentid.sql b/sql/verify_studentid.sql new file mode 100644 index 0000000..504a089 --- /dev/null +++ b/sql/verify_studentid.sql @@ -0,0 +1,31 @@ +-- $Id: verify_studentid.sql 7 2006-12-11 06:27:22Z mspang $ +-- PL/Python trigger to verify student ids for validity +-- Dedicated to office staff who can't type student ids. + +-- To (re)install: +-- su postgres +-- psql ceo < verify_studentid.sql + +-- To uninstall: +-- su postgres +-- echo 'DROP FUNCTION verify_studentid() CASCADE' | psql ceo + +DROP FUNCTION verify_studentid() CASCADE; + +CREATE FUNCTION verify_studentid() RETURNS trigger AS ' + import re + + # update this line if the student id format changes + STUDENTID_REGEX = "^[0-9]{8}$" + + studentid = TD["new"]["studentid"] + if studentid and not re.match(STUDENTID_REGEX, studentid): + plpy.error("student id is invalid (%s)" % studentid) + +' LANGUAGE plpythonu; + +CREATE TRIGGER verify_studentid_insert BEFORE INSERT on members + FOR ROW EXECUTE PROCEDURE verify_studentid(); + +CREATE TRIGGER verify_studentid_update BEFORE UPDATE ON members + FOR ROW EXECUTE PROCEDURE verify_studentid(); diff --git a/sql/verify_term.sql b/sql/verify_term.sql new file mode 100644 index 0000000..49f0351 --- /dev/null +++ b/sql/verify_term.sql @@ -0,0 +1,30 @@ +-- $Id$ +-- PL/Python trigger to verify terms for validity + +-- To (re)install: +-- su postgres +-- psql ceo < verify_term.sql + +-- To uninstall: +-- su postgres +-- echo 'DROP FUNCTION verify_term() CASCADE' | psql ceo + +DROP FUNCTION verify_term() CASCADE; + +CREATE FUNCTION verify_term() RETURNS trigger AS ' + import re + + # update this line if the term format changes + TERM_REGEX = "^[wsf][0-9]{4}$" + + term = TD["new"]["term"] + if term and not re.match(TERM_REGEX, term): + plpy.error("term is invalid (%s)" % term) + +' LANGUAGE plpythonu; + +CREATE TRIGGER verify_term_insert BEFORE INSERT on terms + FOR ROW EXECUTE PROCEDURE verify_term(); + +CREATE TRIGGER verify_term_update BEFORE UPDATE ON terms + FOR ROW EXECUTE PROCEDURE verify_term();