Compare commits

...

67 Commits

Author SHA1 Message Date
Andrew Wang ba50a39700 database cli 2021-09-02 00:37:42 -04:00
Andrew Wang eb5d632606 db-api (#10)
Implement DB endpoints

Co-authored-by: Andrew Wang <someone.zip@gmail.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#10
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-08-29 13:08:35 -04:00
Max Erenberg 7d23fd690f store GSSAPI token in flask.g 2021-08-28 05:51:48 +00:00
Max Erenberg d8e5b1f1d4 update README 2021-08-26 02:26:56 +00:00
Max Erenberg 46881f7a1f update .drone.yml 2021-08-26 02:20:24 +00:00
Max Erenberg e011e98026 use GSSAPI delegation 2021-08-26 02:19:18 +00:00
Max Erenberg 95e167578f remove libsasl2-dev dependency 2021-08-24 20:50:34 +00:00
Max Erenberg 51737585bd add updateprograms CLI 2021-08-24 19:37:05 +00:00
Max Erenberg 831ebf17aa add groups CLI 2021-08-24 05:48:55 +00:00
Max Erenberg 45192d75bf update social media links in welcome message 2021-08-23 23:40:52 +00:00
Max Erenberg e851c77e74 include password in welcome email 2021-08-23 23:36:49 +00:00
Max Erenberg 08a3faaefc add unit tests for members CLI 2021-08-23 23:01:24 +00:00
Max Erenberg 7a8751fd8f Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-08-23 13:59:24 +00:00
Max Erenberg 6917247fdd add members CLI 2021-08-23 13:59:01 +00:00
Rio Liu ad937eebeb Positions API (#7)
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
Co-authored-by: Rio6 <rio.liu@r26.me>
Reviewed-on: public/pyceo#7
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-08-22 17:57:36 -04:00
Max Erenberg 0974a7471b ignore UserAlreadySubscribedError 2021-08-22 06:06:11 +00:00
Max Erenberg 0783588323 announce new user to ceo mailing list 2021-08-22 05:44:41 +00:00
Max Erenberg 7142659a8c force delete Kerberos test principals 2021-08-22 04:36:19 +00:00
Max Erenberg 862dfc01b2 add trigger branches to drone.yml 2021-08-21 07:20:40 +00:00
Max Erenberg bb82945b41 remove hostname from /etc/hosts in auth1 2021-08-21 07:13:36 +00:00
Max Erenberg 38f354c106 add sasl-host to slapd.conf 2021-08-21 06:54:59 +00:00
Max Erenberg 95d083fca1 use our own SPNEGO implementation 2021-08-21 06:27:33 +00:00
Max Erenberg 89e6c541ab add hostname check 2021-08-20 18:46:36 +00:00
Max Erenberg c39eff6ca7 let service container sleep 2021-08-20 18:39:31 +00:00
Max Erenberg e4970bf008 remove search option from resolv.conf 2021-08-20 18:34:29 +00:00
Max Erenberg d11c6af2ec add tests to drone.yml 2021-08-20 18:17:00 +00:00
Max Erenberg 4783621d22 update CI badge 2021-08-20 02:29:25 +00:00
Max Erenberg 14273dcbe6 add drone.yml 2021-08-20 02:24:55 +00:00
Max Erenberg 14c058eb67 use socket.gethostname() in krb5 test 2021-08-20 01:57:53 +00:00
Max Erenberg dc09210d23 add documentation about architecture 2021-08-20 01:41:50 +00:00
Max Erenberg 583fcded9b add test for API request without KRB-CRED 2021-08-19 23:53:13 +00:00
Max Erenberg 46fd926acc add test for RemoteMailmanService 2021-08-19 22:08:48 +00:00
Max Erenberg 490abb302c add simple authz tests 2021-08-19 20:33:44 +00:00
Max Erenberg 26fd8f6f68 remove duplicate function definition 2021-08-19 17:22:34 +00:00
Max Erenberg 2a286579cb Merge branch 'v1' into uwldap_tests 2021-08-19 17:20:47 +00:00
Max Erenberg ecf089c261 Implement Groups API (#6)
This PR implements the /api/groups endpoints.

Closes public/pyceo#2.

Reviewed-on: public/pyceo#6
Co-authored-by: Max Erenberg <merenber@localhost>
Co-committed-by: Max Erenberg <merenber@localhost>
2021-08-19 12:58:59 -04:00
Max Erenberg cc0bc4a638 add tests for Mailman API 2021-08-19 16:14:41 +00:00
Max Erenberg 2273ffa241 add test for krb5 2021-08-19 06:21:30 +00:00
Max Erenberg 12a83ce4c0 remove create_sync_response 2021-08-19 05:11:22 +00:00
Max Erenberg 28c55b2fed add tests for UWLDAP API 2021-08-19 04:56:25 +00:00
Max Erenberg 448692018a add test for group.to_dict() with one member 2021-08-19 00:23:55 +00:00
Max Erenberg 6bf4d75a60 log error message instead of traceback 2021-08-19 00:19:57 +00:00
Max Erenberg 5bda74eaf9 fix test_group_to_dict 2021-08-19 00:05:44 +00:00
Max Erenberg df5d9e5f14 Merge branch 'v1' into groups_api 2021-08-19 00:02:09 +00:00
Max Erenberg 57ab275634 implement /api/groups endpoints 2021-08-18 23:48:17 +00:00
Max Erenberg e370035b25 add cffi as dev dependency 2021-08-18 19:53:30 +00:00
Max Erenberg d78d31eec0 add Kerberos delegation (#5)
This PR adds unconstrained Kerberos delegation to the API.

The client obtains a forwarded TGT and sends it, base64-encoded, in an HTTP header named 'X-KRB5-CRED'. The server reads this credential, creates a new credentials cache for the user, and stores the credential into the new cache. The server can now authenticate to other services (e.g. LDAP) over GSSAPI using the forwarded client's credentials.

Reviewed-on: public/pyceo#5
Co-authored-by: Max Erenberg <merenber@localhost>
Co-committed-by: Max Erenberg <merenber@localhost>
2021-08-18 15:39:14 -04:00
Max Erenberg dd59bea918 add Kerberos delegation 2021-08-18 01:59:24 +00:00
Max Erenberg d82b5a763b use ldap3 instead of python-ldap 2021-08-15 05:04:49 +00:00
Max Erenberg 6cdb41d47b move all tests to top-level folder 2021-08-14 00:11:56 +00:00
Max Erenberg cbf4aa43f8 add tests for uwldap 2021-08-04 20:59:36 +00:00
Max Erenberg 9e4d564a33 move INI file locations 2021-08-04 17:15:06 +00:00
Max Erenberg 3ecf43731f add tests for Group class 2021-08-04 06:33:50 +00:00
Max Erenberg e7bfe36c0b add tests for User class 2021-08-04 05:54:21 +00:00
Max Erenberg 87298e18b3 cast string values in Config 2021-08-04 03:30:19 +00:00
Max Erenberg baeb83b1e2 use ConfigParser 2021-08-03 23:19:33 +00:00
Max Erenberg 4a312378b7 remove mailman transactions 2021-08-03 20:11:13 +00:00
Max Erenberg 96cb2bc808 add updateprograms 2021-08-03 14:09:07 +00:00
Max Erenberg 7c67a07200 use create_sync_response 2021-08-03 03:20:11 +00:00
Max Erenberg c32e565f68 implement renewals and password resets 2021-08-02 08:01:13 +00:00
Max Erenberg da14764687 Merge branch 'v1' of https://git.csclub.uwaterloo.ca/public/pyceo into v1 2021-08-02 07:21:20 +00:00
Max Erenberg ff2ac95d5e add PATCH /api/members/:username endpoint 2021-08-02 07:19:29 +00:00
Max Erenberg 9227552b29 re-send EHLO after STARTTLS 2021-07-31 08:34:06 -04:00
Max Erenberg 7b749701f0 add README 2021-07-24 21:35:09 +00:00
Max Erenberg e966e3f307 add app factory 2021-07-24 21:09:10 +00:00
Max Erenberg 3b78b7ffb4 add MailService and MailmanService 2021-07-24 00:08:22 +00:00
Max Erenberg de0f473881 add base classes for users and groups 2021-07-19 05:47:39 +00:00
235 changed files with 7558 additions and 9251 deletions

41
.drone.yml Normal file
View File

@ -0,0 +1,41 @@
kind: pipeline
type: docker
name: default
steps:
# use the step name to mock out the gethostname() call in our tests
- name: phosphoric-acid
image: python:3.7-buster
# unfortunately we have to do everything in one step because there's no
# way to share system packages between steps
commands:
# install dependencies
- apt update && apt install -y libkrb5-dev libpq-dev python3-dev
- python3 -m venv venv
- . venv/bin/activate
- pip install -r dev-requirements.txt
- pip install -r requirements.txt
# lint
- flake8
# unit + integration tests
- .drone/phosphoric-acid-setup.sh
- pytest -v
services:
- name: auth1
image: debian:buster
commands:
- .drone/auth1-setup.sh
- sleep infinity
- name: coffee
image: debian:buster
commands:
- .drone/coffee-setup.sh
- sleep infinity
trigger:
branch:
- master
- v1

83
.drone/auth1-setup.sh Executable file
View File

@ -0,0 +1,83 @@
#!/bin/bash
set -ex
. .drone/common.sh
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1
# I'm not sure why, but we also need to remove the hosts entry for the
# container's real hostname, otherwise slapd only looks for the principal
# ldap/<container hostname> (this is with the sasl-host option)
sed -E "/\\b$(hostname)\\b/d" /etc/hosts > /tmp/hosts
cat /tmp/hosts > /etc/hosts
rm /tmp/hosts
export DEBIAN_FRONTEND=noninteractive
apt update
apt install -y psmisc
# LDAP
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
# `service slapd stop` doesn't seem to work
killall slapd || true
service nslcd stop || true
rm -rf /etc/ldap/slapd.d
rm /var/lib/ldap/*
cp /usr/share/slapd/DB_CONFIG /var/lib/ldap/DB_CONFIG
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/rfc2307bis.schema /etc/ldap/schema/
cp .drone/csc.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
service nslcd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
# KERBEROS
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
cp .drone/krb5.conf /etc/krb5.conf
cp .drone/kdc.conf /etc/krb5kdc.conf
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm -f /var/lib/krb5kdc/*
echo -e 'krb5\nkrb5' | krb5_newrealm
service krb5-kdc start
service krb5-admin-server start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin.local
addpol -minlength 4 default
addprinc -pw krb5 sysadmin/admin
addprinc -pw krb5 ctdalek
addprinc -pw krb5 regular1
addprinc -randkey host/auth1.csclub.internal
addprinc -randkey ldap/auth1.csclub.internal
ktadd host/auth1.csclub.internal
ktadd ldap/auth1.csclub.internal
EOF
groupadd keytab || true
chgrp keytab /etc/krb5.keytab
chmod 640 /etc/krb5.keytab
usermod -a -G keytab openldap
usermod -a -G sasl openldap
cat <<EOF > /usr/lib/sasl2/slapd.conf
mech_list: plain login gssapi external
pwcheck_method: saslauthd
EOF
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
service saslauthd start
killall slapd && sleep 0.5 && service slapd start
# sync with phosphoric-acid
apt install -y netcat-openbsd
nc -l 0.0.0.0 9000

48
.drone/coffee-setup.sh Executable file
View File

@ -0,0 +1,48 @@
#!/bin/bash
set -ex
. .drone/common.sh
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
export DEBIAN_FRONTEND=noninteractive
apt update
apt install --no-install-recommends -y default-mysql-server postgresql
service mysql stop
sed -E -i 's/^(bind-address[[:space:]]+= 127.0.0.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mysql start
cat <<EOF | mysql
CREATE USER 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
EOF
service postgresql stop
POSTGRES_DIR=/etc/postgresql/11/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres 0.0.0.0/0 md5
local all all peer
host all all localhost md5
local sameuser all md5
host sameuser all 0.0.0.0/0 md5
EOF
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
service postgresql start
su -c "
cat <<EOF | psql
ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres;
EOF" postgres
# sync with phosphoric-acid
apt install -y netcat-openbsd
nc -l 0.0.0.0 9000

17
.drone/common.sh Normal file
View File

@ -0,0 +1,17 @@
# don't resolve container names to *real* CSC machines
sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /tmp/resolv.conf
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
get_ip_addr() {
getent hosts $1 | cut -d' ' -f1
}
add_fqdn_to_hosts() {
ip_addr=$1
hostname=$2
sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts
cp /tmp/hosts /etc/hosts
rm /tmp/hosts
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
}

125
.drone/data.ldif Normal file
View File

@ -0,0 +1,125 @@
dn: dc=csclub,dc=internal
objectClass: top
objectClass: dcObject
objectClass: organization
dc: csclub
o: Computer Science Club
dn: ou=People,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: People
dn: ou=Group,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: Group
dn: ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: organizationalUnit
ou: SUDOers
dn: cn=defaults,ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: sudoRole
cn: defaults
sudoOption: !insults
sudoOption: !lecture
sudoOption: env_reset
sudoOption: listpw=never
sudoOption: shell_noargs
sudoOption: !mail_badpass
dn: cn=syscom,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: syscom
gidNumber: 10001
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=%syscom,ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: sudoRole
cn: %syscom
sudoUser: %syscom
sudoHost: ALL
sudoCommand: ALL
sudoRunAsUser: ALL
dn: cn=adm,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 4
cn: adm
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=office,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 10003
cn: office
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=src,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 40
cn: src
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=staff,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 50
cn: staff
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: uid=ctdalek,ou=People,dc=csclub,dc=internal
cn: Calum Dalek
userPassword: {SASL}ctdalek@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/ctdalek
uid: ctdalek
uidNumber: 20001
gidNumber: 20001
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: s2021
dn: cn=ctdalek,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: ctdalek
gidNumber: 20001
dn: uid=regular1,ou=People,dc=csclub,dc=internal
cn: Regular One
userPassword: {SASL}regular1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/regular1
uid: regular1
uidNumber: 20002
gidNumber: 20002
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: s2021
dn: cn=regular1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: regular1
gidNumber: 20002

19
.drone/kdc.conf Normal file
View File

@ -0,0 +1,19 @@
[kdcdefaults]
kdc_ports = 88
[realms]
CSCLUB.INTERNAL = {
database_name = /var/lib/krb5kdc/principal
admin_keytab = FILE:/etc/krb5kdc/kadm5.keytab
acl_file = /etc/krb5kdc/kadm5.acl
key_stash_file = /etc/krb5kdc/stash
kdc_ports = 88
max_life = 10h 0m 0s
max_renewable_life = 7d 0h 0m 0s
master_key_type = des3-hmac-sha1
supported_enctypes = aes256-cts:normal arcfour-hmac:normal des3-hmac-sha1:normal des3-cbc-sha1:normal des-cbc-crc:normal des:normal des:v4 des:norealm des:onlyrealm des:afs3
default_principal_flags = +preauth
iprop_enable = true
iprop_slave_poll = 2m
iprop_port = 750
}

27
.drone/krb5.conf Normal file
View File

@ -0,0 +1,27 @@
[libdefaults]
default_realm = CSCLUB.INTERNAL
kdc_timesync = 1
ccache_type = 4
forwardable = true
proxiable = true
dns_lookup_kdc = false
dns_lookup_realm = false
allow_weak_crypto = true
[realms]
CSCLUB.INTERNAL = {
kdc = auth1.csclub.internal
admin_server = auth1.csclub.internal
}
[domain_realm]
.csclub.internal = CSCLUB.INTERNAL
csclub.internal = CSCLUB.INTERNAL
[logging]
kdc = SYSLOG:INFO:AUTH
admin_server = SYSLOG:INFO:AUTH
default = SYSLOG:INFO:AUTH

3
.drone/ldap.conf Normal file
View File

@ -0,0 +1,3 @@
BASE dc=csclub,dc=internal
URI ldap://auth1.csclub.internal
SUDOERS_BASE ou=SUDOers,dc=csclub,dc=internal

20
.drone/nsswitch.conf Normal file
View File

@ -0,0 +1,20 @@
# /etc/nsswitch.conf
#
# Example configuration of GNU Name Service Switch functionality.
# If you have the `glibc-doc-reference' and `info' packages installed, try:
# `info libc "Name Service Switch"' for information about this file.
passwd: files ldap
group: files ldap
shadow: files ldap
hosts: files dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
netgroup: nis
sudoers: files ldap

64
.drone/phosphoric-acid-setup.sh Executable file
View File

@ -0,0 +1,64 @@
#!/bin/bash
set -ex
. .drone/common.sh
sync_with() {
host=$1
synced=false
# give it 5 minutes
for i in {1..60}; do
if nc -vz $host 9000 ; then
synced=true
break
fi
sleep 5
done
test $synced = true
}
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) phosphoric-acid
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
add_fqdn_to_hosts $(get_ip_addr coffee) coffee
export DEBIAN_FRONTEND=noninteractive
apt update
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
cp .drone/ldap.conf /etc/ldap/ldap.conf
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
apt install -y netcat-openbsd
sync_with auth1
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin
krb5
addprinc -randkey host/phosphoric-acid.csclub.internal
ktadd host/phosphoric-acid.csclub.internal
addprinc -randkey ceod/phosphoric-acid.csclub.internal
ktadd ceod/phosphoric-acid.csclub.internal
addprinc -randkey ceod/admin
ktadd ceod/admin
EOF
service nslcd start
sync_with coffee
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/

287
.drone/rfc2307bis.schema Normal file
View File

@ -0,0 +1,287 @@
# builtin
#attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber'
# DESC 'An integer uniquely identifying a user in an administrative domain'
# EQUALITY integerMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
# SINGLE-VALUE )
#
# builtin
#attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber'
# DESC 'An integer uniquely identifying a group in an
# administrative domain'
# EQUALITY integerMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
# SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.2 NAME 'gecos'
DESC 'The GECOS field; the common name'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.3 NAME 'homeDirectory'
DESC 'The absolute path to the home directory'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.4 NAME 'loginShell'
DESC 'The path to the login shell'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.6 NAME 'shadowMin'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.7 NAME 'shadowMax'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.8 NAME 'shadowWarning'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.9 NAME 'shadowInactive'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.10 NAME 'shadowExpire'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.11 NAME 'shadowFlag'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple'
DESC 'Netgroup triple'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.15 NAME 'ipServicePort'
DESC 'Service port number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol'
DESC 'Service protocol name'
SUP name )
attributetype ( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber'
DESC 'IP protocol number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber'
DESC 'ONC RPC number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber'
DESC 'IPv4 addresses as a dotted decimal omitting leading
zeros or IPv6 addresses as defined in RFC2373'
SUP name )
attributetype ( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber'
DESC 'IP network as a dotted decimal, eg. 192.168,
omitting leading zeros'
SUP name
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber'
DESC 'IP netmask as a dotted decimal, eg. 255.255.255.0,
omitting leading zeros'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.22 NAME 'macAddress'
DESC 'MAC address in maximal, colon separated hex
notation, eg. 00:00:92:90:ee:e2'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.23 NAME 'bootParameter'
DESC 'rpc.bootparamd parameter'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.24 NAME 'bootFile'
DESC 'Boot image name'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.26 NAME 'nisMapName'
DESC 'Name of a A generic NIS map'
SUP name )
attributetype ( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry'
DESC 'A generic NIS entry'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.28 NAME 'nisPublicKey'
DESC 'NIS public key'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.29 NAME 'nisSecretKey'
DESC 'NIS secret key'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.30 NAME 'nisDomain'
DESC 'NIS domain'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26)
attributetype ( 1.3.6.1.1.1.1.31 NAME 'automountMapName'
DESC 'automount Map Name'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.32 NAME 'automountKey'
DESC 'Automount Key value'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.33 NAME 'automountInformation'
DESC 'Automount information'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount' SUP top AUXILIARY
DESC 'Abstraction of an account with POSIX attributes'
MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory )
MAY ( userPassword $ loginShell $ gecos $
description ) )
objectclass ( 1.3.6.1.1.1.2.1 NAME 'shadowAccount' SUP top AUXILIARY
DESC 'Additional attributes for shadow passwords'
MUST uid
MAY ( userPassword $ description $
shadowLastChange $ shadowMin $ shadowMax $
shadowWarning $ shadowInactive $
shadowExpire $ shadowFlag ) )
objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' SUP top AUXILIARY
DESC 'Abstraction of a group of accounts'
MUST gidNumber
MAY ( userPassword $ memberUid $
description ) )
objectclass ( 1.3.6.1.1.1.2.3 NAME 'ipService' SUP top STRUCTURAL
DESC 'Abstraction an Internet Protocol service.
Maps an IP port and protocol (such as tcp or udp)
to one or more names; the distinguished value of
the cn attribute denotes the services canonical
name'
MUST ( cn $ ipServicePort $ ipServiceProtocol )
MAY description )
objectclass ( 1.3.6.1.1.1.2.4 NAME 'ipProtocol' SUP top STRUCTURAL
DESC 'Abstraction of an IP protocol. Maps a protocol number
to one or more names. The distinguished value of the cn
attribute denotes the protocols canonical name'
MUST ( cn $ ipProtocolNumber )
MAY description )
objectclass ( 1.3.6.1.1.1.2.5 NAME 'oncRpc' SUP top STRUCTURAL
DESC 'Abstraction of an Open Network Computing (ONC)
[RFC1057] Remote Procedure Call (RPC) binding.
This class maps an ONC RPC number to a name.
The distinguished value of the cn attribute denotes
the RPC services canonical name'
MUST ( cn $ oncRpcNumber )
MAY description )
objectclass ( 1.3.6.1.1.1.2.6 NAME 'ipHost' SUP top AUXILIARY
DESC 'Abstraction of a host, an IP device. The distinguished
value of the cn attribute denotes the hosts canonical
name. Device SHOULD be used as a structural class'
MUST ( cn $ ipHostNumber )
MAY ( userPassword $ l $ description $ manager ) )
objectclass ( 1.3.6.1.1.1.2.7 NAME 'ipNetwork' SUP top STRUCTURAL
DESC 'Abstraction of a network. The distinguished value of
the cn attribute denotes the networks canonical name'
MUST ipNetworkNumber
MAY ( cn $ ipNetmaskNumber $ l $ description $ manager ) )
objectclass ( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup' SUP top STRUCTURAL
DESC 'Abstraction of a netgroup. May refer to other netgroups'
MUST cn
MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) )
objectclass ( 1.3.6.1.1.1.2.9 NAME 'nisMap' SUP top STRUCTURAL
DESC 'A generic abstraction of a NIS map'
MUST nisMapName
MAY description )
objectclass ( 1.3.6.1.1.1.2.10 NAME 'nisObject' SUP top STRUCTURAL
DESC 'An entry in a NIS map'
MUST ( cn $ nisMapEntry $ nisMapName )
MAY description )
objectclass ( 1.3.6.1.1.1.2.11 NAME 'ieee802Device' SUP top AUXILIARY
DESC 'A device with a MAC address; device SHOULD be
used as a structural class'
MAY macAddress )
objectclass ( 1.3.6.1.1.1.2.12 NAME 'bootableDevice' SUP top AUXILIARY
DESC 'A device with boot parameters; device SHOULD be
used as a structural class'
MAY ( bootFile $ bootParameter ) )
objectclass ( 1.3.6.1.1.1.2.14 NAME 'nisKeyObject' SUP top AUXILIARY
DESC 'An object with a public and secret key'
MUST ( cn $ nisPublicKey $ nisSecretKey )
MAY ( uidNumber $ description ) )
objectclass ( 1.3.6.1.1.1.2.15 NAME 'nisDomainObject' SUP top AUXILIARY
DESC 'Associates a NIS domain with a naming context'
MUST nisDomain )
objectclass ( 1.3.6.1.1.1.2.16 NAME 'automountMap' SUP top STRUCTURAL
MUST ( automountMapName )
MAY description )
objectclass ( 1.3.6.1.1.1.2.17 NAME 'automount' SUP top STRUCTURAL
DESC 'Automount information'
MUST ( automountKey $ automountInformation )
MAY description )
## namedObject is needed for groups without members
objectclass ( 1.3.6.1.4.1.5322.13.1.1 NAME 'namedObject' SUP top
STRUCTURAL MAY cn )

108
.drone/slapd.conf Normal file
View File

@ -0,0 +1,108 @@
# This is the main slapd configuration file. See slapd.conf(5) for more
# info on the configuration options.
include /etc/ldap/schema/core.schema
include /etc/ldap/schema/cosine.schema
include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid
argsfile /var/run/slapd/slapd.args
#Warning: "stats" is *lots* of logging
loglevel sync
#loglevel stats config sync acl
modulepath /usr/lib/ldap
moduleload back_hdb
moduleload syncprov
moduleload auditlog
moduleload unique
sizelimit unlimited
timelimit unlimited
# consider local connections encrypted
localssf 128
# map kerberos users to ldap users
sasl-realm CSCLUB.INTERNAL
sasl-host auth1.csclub.internal
authz-regexp "uid=([^/=]*),cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
"uid=$1,ou=people,dc=csclub,dc=internal"
authz-regexp "uid=ceod/admin,cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
"cn=ceod,dc=csclub,dc=internal"
access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break
# systems committee get full access
access to *
by dn="cn=ceod,dc=csclub,dc=internal" write
by group/group/uniqueMember="cn=syscom,ou=Group,dc=csclub,dc=internal" write
by * break
# allow office staff to add terms
# the renewal program may do the same
access to attrs=term
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
by dn="cn=renewal,dc=csclub,dc=internal" add
by * read
access to attrs=nonMemberTerm
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
by dn="cn=renewal,dc=csclub,dc=internal" add
by * read
# allow users to change their shells
access to attrs=loginShell
by self write
by * read
# allow simple authentication
access to attrs=userPassword
by anonymous auth
by * none
# allow access to attributes of top; they would otherwise be denied below
access to attrs=@top
by * read
# default permit
access to *
by * read
# main database options
# note: the mdb backend has a horrible bug in 2.4.31
# that causes indexing to destroy the database
database hdb
suffix "dc=csclub,dc=internal"
directory "/var/lib/ldap"
rootdn cn=root,dc=csclub,dc=internal
index default eq
index objectClass
index entryCSN,entryUUID
index uid,uidNumber
index cn,gidNumber
index uniqueMember,memberUid
index sudoUser,sudoHost pres,sub,eq
index term,nonMemberTerm
index mailLocalAddress
index modifyTimestamp,createTimestamp
# log all changes to the directory
overlay auditlog
auditlog /var/log/ldap/audit.log
# enforce uniqueness of usernames etc.
overlay unique
unique_uri ldap:///ou=People,dc=csclub,dc=internal?uid,uidNumber?sub
unique_uri ldap:///ou=Group,dc=csclub,dc=internal?cn,gidNumber?sub
# this is the master server
overlay syncprov
syncprov-checkpoint 100 10
syncprov-sessionlog 100

View File

@ -1,4 +0,0 @@
[DEFAULT]
sign-tags = True
posttag = git push /users/git/public/pyceo.git --tags
debian-tag=v%(version)s

10
.gitignore vendored
View File

@ -1,5 +1,7 @@
/build-stamp
/build
__pycache__/
*.pyc
/build-ceo
/build-ceod
/venv/
.vscode/
*.o
*.so
.idea/

173
README.md Normal file
View File

@ -0,0 +1,173 @@
# pyceo
[![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg?ref=refs/heads/v1)](https://ci.csclub.uwaterloo.ca/public/pyceo)
CEO (**C**SC **E**lectronic **O**ffice) is the tool used by CSC to manage
club accounts and memberships. See [architecture.md](architecture.md) for an
overview of its architecture.
## Development
First, make sure that you have installed the
[syscom dev environment](https://git.uwaterloo.ca/csc/syscom-dev-environment).
This will setup all of the services needed for ceo to work. You should clone
this repo in the phosphoric-acid container under ctdalek's home directory; you
will then be able to access it from any container thanks to NFS.
### Environment setup
Once you have the dev environment setup, there are a few more steps you'll
need to do for ceo.
#### Kerberos principals
First, you'll need `ceod/<hostname>` principals for each of phosphoric-acid,
coffee and mail. (coffee is taking over the role of caffeine for the DB
endpoints). For example, in the phosphoric-acid container:
```sh
kadmin -p sysadmin/admin
<password is krb5>
addprinc -randkey ceod/phosphoric-acid.csclub.internal
ktadd ceod/phosphoric-acid.csclub.internal
```
Do this for coffee and mail as well. You need to actually be in the
appropriate container when running these commands, since the credentials
are being added to the local keytab.
On phosphoric-acid, you will additionally need to create a principal
called `ceod/admin` (remember to addprinc **and** ktadd).
#### Database
**Note**: The instructions below apply to the dev environment only; in
production, the DB superusers should be restricted to the host where
the DB is running.
Attach to the coffee container, run `mysql`, and run the following:
```
CREATE USER 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
```
(In prod, the superuser should have '@localhost' appended to its name.)
Now open /etc/mysql/mariadb.conf.d/50-server.cnf and comment out the following line:
```
bind-address = 127.0.0.1
```
Then restart MariaDB:
```
systemctl restart mariadb
```
Install PostgreSQL in the container:
```
apt install -y postgresql
```
Modify the superuser `postgres` for password authentication and restrict new users:
```
su postgres
psql
ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres;
```
Create a new `pg_hba.conf`:
```
cd /etc/postgresql/<version>/<branch>/
mv pg_hba.conf pg_hba.conf.old
```
```
# new pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres 0.0.0.0/0 md5
local all all peer
host all all localhost md5
local sameuser all md5
host sameuser all 0.0.0.0/0 md5
```
**Warning**: in prod, the postgres user should only be allowed to connect locally,
so the relevant snippet in pg_hba.conf should look something like
```
local all postgres md5
host all postgres localhost md5
host all postgres 0.0.0.0/0 reject
host all postgres ::/0 reject
```
Add the following to postgresql.conf:
```
listen_addresses = '*'
```
Now restart PostgreSQL:
```
systemctl restart postgresql
```
**In prod**, users can login remotely but superusers (`postgres` and `mysql`) are only
allowed to login from the database host.
#### Mailman
You should create the following mailing lists from the mail container:
```sh
/opt/mailman3/bin/mailman create syscom@csclub.internal
/opt/mailman3/bin/mailman create syscom-alerts@csclub.internal
/opt/mailman3/bin/mailman create exec@csclub.internal
/opt/mailman3/bin/mailman create ceo@csclub.internal
```
See https://git.uwaterloo.ca/csc/syscom-dev-environment/-/tree/master/mail
for instructions on how to access the Mailman UI from your browser.
If you want to actually see the archived messages, you'll
need to tweak the settings for each list from the UI so that non-member
messages get accepted (by default they get held).
#### Dependencies
Next, install and activate a virtualenv:
```sh
sudo apt install libkrb5-dev libpq-dev python3-dev
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
pip install -r dev-requirements.txt
```
## Running the application
ceod is a distributed application, with instances on different hosts offering
different services.
Therefore, you will need to run ceod on multiple hosts. Currently, those are
phosphoric-acid, mail and caffeine (in the dev environment, caffeine is
replaced by coffee).
To run ceod on a single host (as root, since the app needs to read the keytab):
```sh
export FLASK_APP=ceod.api
export FLASK_ENV=development
flask run -h 0.0.0.0 -p 9987
```
Sometimes changes you make in the source code don't show up while Flask
is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
restart the app.
## Interacting with the application
The client part of ceo hasn't been written yet, so we'll use curl to
interact with ceod for now.
ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication,
and TLS for confidentiality and integrity. In development mode, TLS can be
disabled.
First, make sure that your version of curl has been compiled with SPNEGO
support:
```sh
curl -V
```
Your should see 'SPNEGO' in the 'Features' section.
Here's an example of making a request to an endpoint which writes to LDAP:
```sh
# Get a Kerberos TGT first
kinit
# Make the request
curl --negotiate -u : --service-name ceod --delegation always \
-d '{"uid":"test_1","cn":"Test One","program":"Math","terms":["s2021"]}' \
-X POST http://phosphoric-acid:9987/api/members
```

70
architecture.md Normal file
View File

@ -0,0 +1,70 @@
# Architecture
ceo is a distributed HTTP application running on three hosts. As of this
writing, those are phosphoric-acid, mail and caffeine (coffee in the dev
environment).
* The `mail` host provides the `/api/mailman` endpoints. This is because
the REST API for Mailman3 is currently configured to run on localhost.
* The `caffeine` host provides the `/api/db` endpoints. This is because
the root account of MySQL and PostgreSQL on caffeine can only be accessed
locally.
* All other endpoints are provided by `phosphoric-acid`. phosphoric-acid is the
only host with the `ceod/admin` Kerberos key which means it is the only host
which can create new principals and reset passwords.
Some endpoints can be accessed from multiple hosts. This is explained more in
[Security](#security).
Interestingly, ceod instances can actually make API calls to each other. For
example, when the instance on phosphoric-acid creates a new user, it will
make a call to the instance on mail to subscribe the user to the csc-general
mailing list.
## Security
In the old ceo, most LDAP modifications were performed on the client side,
using the client's Kerberos credentials to authenticate to LDAP via GSSAPI.
Using the client's credentials is desirable since we currently have custom
authz rules in our slapd.conf on auth1 and auth2. If we were to use the
server's credentials instead, this would result in two different sets of
authz rules - one at the API layer and one at the OpenLDAP layer - and
syscom members would very likely forget to update both at the same time.
So, we want a way for the server to use the client's credentials when
interacting with LDAP. The most secure way to do this is via a Kerberos
extension called "constrained delegation", or [S4U](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-sfu/1fb9caca-449f-4183-8f7a-1a5fc7e7290a).
While the MIT KDC, which we are currently using, does provide support for S4U,
this [requires using LDAP as a database backend](https://k5wiki.kerberos.org/wiki/Projects/ConstrainedDelegation#CHECK_ALLOWED_TO_DELEGATE),
which we are *not* using. While it is theoretically possible to migrate our
KDC databases to LDAP, this would be a very risky operation, and probably
not worth it if ceo is the only app which will use it.
Therefore, we will use unconstrained delegation. The client essentially
forwards their TGT to ceod, which uses it to access other services over GSSAPI
on the client's behalf. We accomplish this using GSSAPI delegation (i.e. set
the GSS_C_DELEG_FLAG when creating a security context).
Since the client's credentials are used when interacting with LDAP, this means
that most LDAP-related endpoints can actually be accessed from any host.
Only the Kerberos-specific endpoints (e.g. resetting a password) truly need
to be on phosphoric-acid.
### Authentication
The REST API uses SPNEGO for authetication via the HTTP Negotiate
Authentication scheme (https://www.ietf.org/rfc/rfc4559.txt). The API
does not verify that the user actually knows the key for the service ticket;
therefore, TLS is necessary to prevent MITM attacks. (TLS is also necessary
to protect the KRB-CRED message, which is unencrypted.)
SPNEGO is pretty awkward, to be honest, as it completely breaks the stateless
nature of HTTP. If we decide that SPNEGO is too much trouble, we should switch
to plain HTTP cookies instead, and cache them somewhere in the client's home
directory.
## Web UI
For future contributors: if you wish to make ceo accessible from the browser,
you will need to add some kind of "Kerberos gateway" logic to the API such
that the user's password can be used to obtain Kerberos tickets. One possible
implementation would be to prompt the user for a password, obtain a TGT,
then encrypt the TGT and store it as a JWT in the user's browser. The API
can decrypt the JWT later and use it as long as the ticket has not expired;
otherwise, the user will be re-prompted for their password.

40
bin/ceo
View File

@ -1,40 +0,0 @@
#!/usr/bin/python
import sys, ldap
from getpass import getpass
import ceo.urwid.main
import ceo.console.main
from ceo import ldapi, members
def start():
try:
if len(sys.argv) == 1:
print "Reading config file...",
members.configure()
print "Connecting to LDAP..."
members.connect(AuthCallback())
ceo.urwid.main.start()
else:
members.configure()
members.connect(AuthCallback())
ceo.console.main.start()
except ldap.LOCAL_ERROR, e:
print ldapi.format_ldaperror(e)
except ldap.INSUFFICIENT_ACCESS, e:
print ldapi.format_ldaperror(e)
print "You probably aren't permitted to do whatever you just tried."
print "Admittedly, ceo probably shouldn't have crashed either."
class AuthCallback:
def callback(self, error):
try:
print "Password: ",
return getpass("")
except KeyboardInterrupt:
print ""
sys.exit(1)
if __name__ == '__main__':
start()

View File

@ -1,5 +0,0 @@
if test -e .git; then
git-buildpackage --git-ignore-new -us -uc
else
debuild -us -uc
fi

1
ceo/.gitignore vendored
View File

@ -1 +0,0 @@
/ceo_pb2.py

View File

@ -1 +0,0 @@
"""CSC Electronic Office"""

4
ceo/__main__.py Normal file
View File

@ -0,0 +1,4 @@
from .cli import cli
if __name__ == '__main__':
cli(obj={})

1
ceo/cli/__init__.py Normal file
View File

@ -0,0 +1 @@
from .entrypoint import cli

98
ceo/cli/database.py Normal file
View File

@ -0,0 +1,98 @@
import click
import os
from zope import component
from ceo_common.interfaces import IConfig
from ..utils import http_post, http_get
from .utils import handle_sync_response
# possible to make default [username] argument the user calling
def check_file_path(file):
if os.path.exists(file):
if os.path.isfile(file):
click.echo(f"{file} will be overwritten")
click.confirm('Do you want to continue?', abort=True)
if os.path.isdir(file):
click.echo(f"Error there exists a directory at {file}")
raise click.Abort()
def mysql_create_info_file(file, username, password):
cfg_srv = component.getUtility(IConfig)
mysql_host = cfg_srv.get('mysql_host')
info = f"""MySQL Database Information for {username}
Your new MySQL database was created. To connect, use the following options:
Database: {username}
Username: {username}
Password: {password}
Host: {mysql_host}
On {mysql_host} to connect using the MySQL command-line client use
mysql {username} -u {username} -p
From other CSC servers you can connect using
mysql {username} -h {mysql_host} -u {username} -p
"""
with click.open_file(file, "w") as f:
f.write(info)
os.chown(file, username, username)
os.chmod(file, 0o640)
def psql_create_info_file(file, username, password):
pass
@click.group(short_help='Perform operations on MySQL')
def mysql():
pass
@mysql.command(short_help='Create a MySQL database for the user')
@click.argument('username')
def create(username):
resp = http_get(f'/api/members/{username}')
result = handle_sync_response(resp)
info_file_path = os.path.join(result['home_directory'], "ceo-mysql-info")
check_file_path(info_file_path)
resp = http_post(f'/api/db/mysql/{username}')
result = handle_sync_response(resp)
password = result['password']
mysql_create_info_file(info_file_path, username, password)
click.echo(f"""MySQL database {username} with password {password} has been created
The password and more details have been written to {info_file_path}""")
@mysql.command(short_help='Reset the password for MySQL user')
@click.argument('username')
def reset(username):
pass
@click.group(short_help='Perform operations on PostgreSQL')
def postgresql():
pass
@postgresql.command(short_help='Create a PostgreSQL database for the user')
@click.argument('username')
def create(username):
pass
@postgresql.command(short_help='Reset password for PostgreSQL user')
@click.argument('username')
def reset(username):
pass

51
ceo/cli/entrypoint.py Normal file
View File

@ -0,0 +1,51 @@
import importlib.resources
import os
import socket
import click
from zope import component
from ..krb_check import krb_check
from .members import members
from .groups import groups
from .updateprograms import updateprograms
from .database import mysql, postgresql
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
@click.group()
@click.pass_context
def cli(ctx):
# ensure ctx exists and is a dict
ctx.ensure_object(dict)
princ = krb_check()
user = princ[:princ.index('@')]
ctx.obj['user'] = user
if os.environ.get('PYTEST') != '1':
register_services()
cli.add_command(members)
cli.add_command(groups)
cli.add_command(updateprograms)
cli.add_command(mysql)
cli.add_command(postgresql)
def register_services():
# Config
# This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
with importlib.resources.path('tests', 'ceo_dev.ini') as p:
config_file = p.__fspath__()
else:
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
cfg = Config(config_file)
component.provideUtility(cfg, IConfig)
# HTTPService
http_client = HTTPClient()
component.provideUtility(http_client, IHTTPClient)

148
ceo/cli/groups.py Normal file
View File

@ -0,0 +1,148 @@
from typing import Dict
import click
from zope import component
from ..utils import http_post, http_get, http_delete
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
check_if_in_development
from ceo_common.interfaces import IConfig
from ceod.transactions.groups import (
AddGroupTransaction,
AddMemberToGroupTransaction,
RemoveMemberFromGroupTransaction,
DeleteGroupTransaction,
)
@click.group(short_help='Perform operations on CSC groups/clubs')
def groups():
pass
@groups.command(short_help='Add a new group')
@click.argument('group_name')
@click.option('-d', '--description', help='Group description', prompt=True)
def add(group_name, description):
click.echo('The following group will be created:')
lines = [
('cn', group_name),
('description', description),
]
print_colon_kv(lines)
click.confirm('Do you want to continue?', abort=True)
body = {
'cn': group_name,
'description': description,
}
operations = AddGroupTransaction.operations
resp = http_post('/api/groups', json=body)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
print_group_lines(result)
def print_group_lines(result: Dict):
"""Pretty-print a group JSON response."""
lines = [
('cn', result['cn']),
('description', result.get('description', 'Unknown')),
('gid_number', str(result['gid_number'])),
]
for i, member in enumerate(result['members']):
if i == 0:
prefix = 'members'
else:
prefix = ''
lines.append((prefix, member['cn'] + ' (' + member['uid'] + ')'))
print_colon_kv(lines)
@groups.command(short_help='Get info about a group')
@click.argument('group_name')
def get(group_name):
resp = http_get('/api/groups/' + group_name)
result = handle_sync_response(resp)
print_group_lines(result)
@groups.command(short_help='Add a member to a group')
@click.argument('group_name')
@click.argument('username')
@click.option('--no-subscribe', is_flag=True, default=False,
help='Do not subscribe the member to any auxiliary mailing lists.')
def addmember(group_name, username, no_subscribe):
click.confirm(f'Are you sure you want to add {username} to {group_name}?',
abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
url = f'/api/groups/{group_name}/members/{username}'
operations = AddMemberToGroupTransaction.operations
if no_subscribe:
url += '?subscribe_to_lists=false'
operations.remove('subscribe_user_to_auxiliary_mailing_lists')
resp = http_post(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
lines = []
for i, group in enumerate(result['added_to_groups']):
if i == 0:
prefix = 'Added to groups'
else:
prefix = ''
lines.append((prefix, group))
for i, mailing_list in enumerate(result.get('subscribed_to_lists', [])):
if i == 0:
prefix = 'Subscribed to lists'
else:
prefix = ''
if '@' not in mailing_list:
mailing_list += '@' + base_domain
lines.append((prefix, mailing_list))
print_colon_kv(lines)
@groups.command(short_help='Remove a member from a group')
@click.argument('group_name')
@click.argument('username')
@click.option('--no-unsubscribe', is_flag=True, default=False,
help='Do not unsubscribe the member from any auxiliary mailing lists.')
def removemember(group_name, username, no_unsubscribe):
click.confirm(f'Are you sure you want to remove {username} from {group_name}?',
abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
url = f'/api/groups/{group_name}/members/{username}'
operations = RemoveMemberFromGroupTransaction.operations
if no_unsubscribe:
url += '?unsubscribe_from_lists=false'
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists')
resp = http_delete(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
lines = []
for i, group in enumerate(result['removed_from_groups']):
if i == 0:
prefix = 'Removed from groups'
else:
prefix = ''
lines.append((prefix, group))
for i, mailing_list in enumerate(result.get('unsubscribed_from_lists', [])):
if i == 0:
prefix = 'Unsubscribed from lists'
else:
prefix = ''
if '@' not in mailing_list:
mailing_list += '@' + base_domain
lines.append((prefix, mailing_list))
print_colon_kv(lines)
@groups.command(short_help='Delete a group')
@click.argument('group_name')
def delete(group_name):
check_if_in_development()
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
resp = http_delete(f'/api/groups/{group_name}')
handle_stream_response(resp, DeleteGroupTransaction.operations)

218
ceo/cli/members.py Normal file
View File

@ -0,0 +1,218 @@
import sys
from typing import Dict
import click
from zope import component
from ..utils import http_post, http_get, http_patch, http_delete, get_failed_operations
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
check_if_in_development
from ceo_common.interfaces import IConfig
from ceo_common.model import Term
from ceod.transactions.members import (
AddMemberTransaction,
DeleteMemberTransaction,
)
@click.group(short_help='Perform operations on CSC members and club reps')
def members():
pass
@members.command(short_help='Add a new member or club rep')
@click.argument('username')
@click.option('--cn', help='Full name', prompt='Full name')
@click.option('--program', required=False, help='Academic program')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', prompt='Number of terms')
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
@click.option('--forwarding-address', required=False,
help=('Forwarding address to set in ~/.forward. '
'Default is UW address. '
'Set to the empty string to disable forwarding.'))
def add(username, cn, program, num_terms, clubrep, forwarding_address):
cfg = component.getUtility(IConfig)
uw_domain = cfg.get('uw_domain')
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
terms = list(map(str, terms))
if forwarding_address is None:
forwarding_address = username + '@' + uw_domain
click.echo("The following user will be created:")
lines = [
('uid', username),
('cn', cn),
]
if program is not None:
lines.append(('program', program))
if clubrep:
lines.append(('non-member terms', ','.join(terms)))
else:
lines.append(('member terms', ','.join(terms)))
if forwarding_address != '':
lines.append(('forwarding address', forwarding_address))
print_colon_kv(lines)
click.confirm('Do you want to continue?', abort=True)
body = {
'uid': username,
'cn': cn,
}
if program is not None:
body['program'] = program
if clubrep:
body['non_member_terms'] = terms
else:
body['terms'] = terms
if forwarding_address != '':
body['forwarding_addresses'] = [forwarding_address]
operations = AddMemberTransaction.operations
if forwarding_address == '':
# don't bother displaying this because it won't be run
operations.remove('set_forwarding_addresses')
resp = http_post('/api/members', json=body)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
print_user_lines(result)
failed_operations = get_failed_operations(data)
if 'send_welcome_message' in failed_operations:
click.echo(click.style(
'Warning: welcome message was not sent. You now need to manually '
'send the user their password.', fg='yellow'))
def print_user_lines(result: Dict):
"""Pretty-print a user JSON response."""
lines = [
('uid', result['uid']),
('cn', result['cn']),
('program', result.get('program', 'Unknown')),
('UID number', result['uid_number']),
('GID number', result['gid_number']),
('login shell', result['login_shell']),
('home directory', result['home_directory']),
('is a club', result['is_club']),
]
if 'forwarding_addresses' in result:
if len(result['forwarding_addresses']) != 0:
lines.append(('forwarding addresses', result['forwarding_addresses'][0]))
for address in result['forwarding_addresses'][1:]:
lines.append(('', address))
if 'terms' in result:
lines.append(('terms', ','.join(result['terms'])))
if 'non_member_terms' in result:
lines.append(('non-member terms', ','.join(result['non_member_terms'])))
if 'password' in result:
lines.append(('password', result['password']))
print_colon_kv(lines)
@members.command(short_help='Get info about a user')
@click.argument('username')
def get(username):
resp = http_get('/api/members/' + username)
result = handle_sync_response(resp)
print_user_lines(result)
@members.command(short_help="Replace a user's login shell or forwarding addresses")
@click.argument('username')
@click.option('--login-shell', required=False, help='Login shell')
@click.option('--forwarding-addresses', required=False,
help=(
'Comma-separated list of forwarding addresses. '
'Set to the empty string to disable forwarding.'
))
def modify(username, login_shell, forwarding_addresses):
if login_shell is None and forwarding_addresses is None:
click.echo('Nothing to do.')
sys.exit()
operations = []
body = {}
if login_shell is not None:
body['login_shell'] = login_shell
operations.append('replace_login_shell')
click.echo('Login shell will be set to: ' + login_shell)
if forwarding_addresses is not None:
if forwarding_addresses == '':
forwarding_addresses = []
else:
forwarding_addresses = forwarding_addresses.split(',')
body['forwarding_addresses'] = forwarding_addresses
operations.append('replace_forwarding_addresses')
prefix = '~/.forward will be set to: '
if len(forwarding_addresses) > 0:
click.echo(prefix + forwarding_addresses[0])
for address in forwarding_addresses[1:]:
click.echo((' ' * len(prefix)) + address)
else:
click.echo(prefix)
click.confirm('Do you want to continue?', abort=True)
resp = http_patch('/api/members/' + username, json=body)
handle_stream_response(resp, operations)
@members.command(short_help="Renew a member or club rep's membership")
@click.argument('username')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', prompt='Number of terms')
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
def renew(username, num_terms, clubrep):
resp = http_get('/api/members/' + username)
result = handle_sync_response(resp)
max_term = None
current_term = Term.current()
if clubrep and 'non_member_terms' in result:
max_term = max(Term(s) for s in result['non_member_terms'])
elif not clubrep and 'terms' in result:
max_term = max(Term(s) for s in result['terms'])
if max_term is not None and max_term >= current_term:
next_term = max_term + 1
else:
next_term = Term.current()
terms = [next_term + i for i in range(num_terms)]
terms = list(map(str, terms))
if clubrep:
body = {'non_member_terms': terms}
click.echo('The following non-member terms will be added: ' + ','.join(terms))
else:
body = {'terms': terms}
click.echo('The following member terms will be added: ' + ','.join(terms))
click.confirm('Do you want to continue?', abort=True)
resp = http_post(f'/api/members/{username}/renew', json=body)
handle_sync_response(resp)
click.echo('Done.')
@members.command(short_help="Reset a user's password")
@click.argument('username')
def pwreset(username):
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True)
resp = http_post(f'/api/members/{username}/pwreset')
result = handle_sync_response(resp)
click.echo('New password: ' + result['password'])
@members.command(short_help="Delete a user")
@click.argument('username')
def delete(username):
check_if_in_development()
click.confirm(f"Are you sure you want to delete {username}?", abort=True)
resp = http_delete(f'/api/members/{username}')
handle_stream_response(resp, DeleteMemberTransaction.operations)

35
ceo/cli/updateprograms.py Normal file
View File

@ -0,0 +1,35 @@
import click
from ..utils import http_post
from .utils import handle_sync_response, print_colon_kv
@click.command(short_help="Sync the 'program' attribute with UWLDAP")
@click.option('--dry-run', is_flag=True, default=False)
@click.option('--members', required=False)
def updateprograms(dry_run, members):
body = {}
if dry_run:
body['dry_run'] = True
if members is not None:
body['members'] = ','.split(members)
if not dry_run:
click.confirm('Are you sure that you want to sync programs with UWLDAP?', abort=True)
resp = http_post('/api/uwldap/updateprograms', json=body)
result = handle_sync_response(resp)
if len(result) == 0:
click.echo('All programs are up-to-date.')
return
if dry_run:
click.echo('Members whose program would be changed:')
else:
click.echo('Members whose program was changed:')
lines = []
for uid, csc_program, uw_program in result:
csc_program = csc_program or 'Unknown'
csc_program = click.style(csc_program, fg='yellow')
uw_program = click.style(uw_program, fg='green')
lines.append((uid, csc_program + ' -> ' + uw_program))
print_colon_kv(lines)

121
ceo/cli/utils.py Normal file
View File

@ -0,0 +1,121 @@
import json
import socket
import sys
from typing import List, Tuple, Dict
import click
import requests
from ..operation_strings import descriptions as op_desc
class Abort(click.ClickException):
"""Abort silently."""
def __init__(self, exit_code=1):
super().__init__('')
self.exit_code = exit_code
def show(self):
pass
def print_colon_kv(pairs: List[Tuple[str, str]]):
"""
Pretty-print a list of key-value pairs such that the key and value
columns align.
Example:
key1: value1
key1000: value2
"""
maxlen = max(len(key) for key, val in pairs)
for key, val in pairs:
if key != '':
click.echo(key + ': ', nl=False)
else:
# assume this is a continuation from the previous line
click.echo(' ', nl=False)
extra_space = ' ' * (maxlen - len(key))
click.echo(extra_space, nl=False)
click.echo(val)
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
"""
Print output to the console while operations are being streamed
from the server over HTTP.
Returns the parsed JSON data streamed from the server.
"""
if resp.status_code != 200:
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
click.echo(op_desc[operations[0]] + '... ', nl=False)
idx = 0
data = []
for line in resp.iter_lines(decode_unicode=True, chunk_size=8):
d = json.loads(line)
data.append(d)
if d['status'] == 'aborted':
click.echo(click.style('ABORTED', fg='red'))
click.echo('The transaction was rolled back.')
click.echo('The error was: ' + d['error'])
click.echo('Please check the ceod logs.')
sys.exit(1)
elif d['status'] == 'completed':
if idx < len(operations):
click.echo('Skipped')
click.echo('Transaction successfully completed.')
return data
operation = d['operation']
oper_failed = False
err_msg = None
prefix = 'failed_to_'
if operation.startswith(prefix):
operation = operation[len(prefix):]
oper_failed = True
# sometimes the operation looks like
# "failed_to_do_something: error message"
if ':' in operation:
operation, err_msg = operation.split(': ', 1)
while idx < len(operations) and operations[idx] != operation:
click.echo('Skipped')
idx += 1
if idx == len(operations):
break
click.echo(op_desc[operations[idx]] + '... ', nl=False)
if idx == len(operations):
click.echo('Unrecognized operation: ' + operation)
continue
if oper_failed:
click.echo(click.style('Failed', fg='red'))
if err_msg is not None:
click.echo(' Error message: ' + err_msg)
else:
click.echo(click.style('Done', fg='green'))
idx += 1
if idx < len(operations):
click.echo(op_desc[operations[idx]] + '... ', nl=False)
raise Exception('server response ended abruptly')
def handle_sync_response(resp: requests.Response):
"""
Exit the program if the request was not successful.
Returns the parsed JSON response.
"""
if resp.status_code != 200:
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
return resp.json()
def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'):
click.echo('This command may only be called during development.')
raise Abort()

View File

@ -1,162 +0,0 @@
"""
Configuration Utility Module
This module contains functions to load and verify very simple configuration
files. Python supports ".ini" files, which suck, so this module is used
instead.
Example Configuration File:
include /path/to/other.cf
# these values are the same:
name_protected = "Michael Spang"
name_unprotected = Michael Spang
# these values are not the same:
yes_no = " yes"
no_yes = yes
# this value is an integer
arbitrary_number=2
# this value is not an integer
arbitrary_string="2"
# this is a key with no value
csclub
# this key contains whitespace
white space = sure, why not
# these two lines are treated as one
long line = first line \\
second line
Resultant Dictionary:
{
'name_protected': 'Michael Spang',
'name_unprotected:' 'Michael Spang',
'yes_no': ' yes',
'no_yes': 'yes',
'arbirary_number': 2,
'arbitrary_string': '2',
'csclub': None,
'white space': 'sure, why not'
'long line': 'first line \\n second line'
... (data from other.cf) ...
}
"""
from curses.ascii import isspace
class ConfigurationException(Exception):
"""Exception class for incomplete and incorrect configurations."""
def read(filename, included=None):
"""
Function to read a configuration file into a dictionary.
Parmaeters:
filename - the file to read
included - files previously read (internal)
Exceptions:
IOError - when the configuration file cannot be read
"""
if not included:
included = []
if filename in included:
return {}
included.append(filename)
conffile = open(filename)
options = {}
while True:
line = conffile.readline()
if line == '':
break
# remove comments
if '#' in line:
line = line[:line.find('#')]
# combine lines when the newline is escaped with \
while len(line) > 1 and line[-2] == '\\':
line = line[:-2] + line[-1]
next = conffile.readline()
line += next
if next == '':
break
line = line.strip()
# process include statements
if line.find("include") == 0 and isspace(line[7]):
filename = line[8:].strip()
options.update(read(filename, included))
continue
# split 'key = value' into key and value and strip results
pair = map(str.strip, line.split('=', 1))
# found key and value
if len(pair) == 2:
key, val = pair
# found quoted string?
if val and val[0] == val[-1] == '"':
val = val[1:-1]
# unquoted, found num?
elif val:
try:
if "." in val:
val = float(val)
elif val[0] == '0':
val = int(val, 8)
else:
val = int(val)
except ValueError:
pass
# save key and value
options[key] = val
# found only key, value = None
elif len(pair[0]) > 1:
key = pair[0]
options[key] = None
return options
def check_string_fields(filename, field_list, cfg):
"""Function to verify thatfields are strings."""
for field in field_list:
if field not in cfg or type(cfg[field]) is not str:
raise ConfigurationException('expected string value for option "%s" in "%s"' % (field, filename))
def check_integer_fields(filename, field_list, cfg):
"""Function to verify that fields are integers."""
for field in field_list:
if field not in cfg or type(cfg[field]) not in (int, long):
raise ConfigurationException('expected numeric value for option "%s" in "%s"' % (field, filename))
def check_float_fields(filename, field_list, cfg):
"""Function to verify that fields are integers or floats."""
for field in field_list:
if field not in cfg or type(cfg[field]) not in (float, long, int):
raise ConfigurationException('expected float value for option "%s" in "%s"' % (field, filename))

View File

@ -1 +0,0 @@
"""Console Interface"""

View File

@ -1,40 +0,0 @@
import sys, ldap
from ceo import members, uwldap, terms, ldapi
def max_term(term1, term2):
if terms.compare(term1, term2) > 0:
return term1
else:
return term2
class ExpiredAccounts:
help = '''
expiredaccounts [--email]
Displays a list of expired accounts. If --email is specified, expired account
owners will be emailed.
'''
def main(self, args):
send_email = False
if len(args) == 1 and args[0] == '--email':
sys.stderr.write("If you want to send an account expiration notice to " \
"these users then type 'Yes, do this' and hit enter\n")
if raw_input() == 'Yes, do this':
send_email = True
uwl = ldap.initialize(uwldap.uri())
mlist = members.expired_accounts()
for member in mlist.values():
term = "f0000"
term = reduce(max_term, member.get("term", []), term)
term = reduce(max_term, member.get("nonMemberTerm", []), term)
expiredfor = terms.delta(term, terms.current())
if expiredfor <= 3:
uid = member['uid'][0]
name = member['cn'][0]
email = None
print '%s (expired for %d terms)' % (uid.ljust(12), expiredfor)
if send_email:
print " sending mail to %s" % uid
members.send_account_expired_email(name, uid)

View File

@ -1,27 +0,0 @@
from ceo import members, terms
def max_term(term1, term2):
if terms.compare(term1, term2) > 0:
return term1
else:
return term2
class Inactive:
help = '''
inactive delta-terms
Prints a list of accounts that have been inactive (i.e. unpaid) for
delta-terms.
'''
def main(self, args):
if len(args) != 1:
print self.help
return
delta = int(args[0])
mlist = members.list_all()
for member in mlist.values():
term = "f0000"
term = reduce(max_term, member.get("term", []), term)
term = reduce(max_term, member.get("nonMemberTerm", []), term)
if terms.delta(term, terms.current()) >= delta:
print "%s %s" % (member['uid'][0].ljust(12), term)

View File

@ -1,49 +0,0 @@
import sys, ldap, termios
from ceo import members, terms, uwldap, ldapi
from ceo.console.memberlist import MemberList
from ceo.console.updateprograms import UpdatePrograms
from ceo.console.expiredaccounts import ExpiredAccounts
from ceo.console.inactive import Inactive
from ceo.console.mysql import MySQL
commands = {
'memberlist' : MemberList(),
'updateprograms' : UpdatePrograms(),
'expiredaccounts' : ExpiredAccounts(),
'inactive': Inactive(),
'mysql': MySQL(),
}
help_opts = [ '--help', '-h' ]
def start():
args = sys.argv[1:]
if args[0] in help_opts:
help()
elif args[0] in commands:
command = commands[args[0]]
if len(args) >= 2 and args[1] in help_opts:
print command.help
else:
command.main(args[1:])
else:
print "Invalid command '%s'" % args[0]
def help():
args = sys.argv[2:]
if len(args) == 1:
if args[0] in commands:
print commands[args[0]].help
else:
print 'Unknown command %s.' % args[0]
else:
print ''
print 'To run the ceo GUI, type \'ceo\''
print ''
print 'To run a ceo console command, type \'ceo command\''
print ''
print 'Available console commands:'
for c in commands:
print ' %s' % c
print ''
print 'Run \'ceo command --help\' for help on a specific command.'
print ''

View File

@ -1,24 +0,0 @@
from ceo import members, terms
class MemberList:
help = '''
memberlist [term]
Displays a list of members for a term; defaults to the current term if term
is not given.
'''
def main(self, args):
mlist = {}
if len(args) == 1:
mlist = members.list_term(args[0])
else:
mlist = members.list_term(terms.current())
dns = mlist.keys()
dns.sort()
for dn in dns:
member = mlist[dn]
print '%s %s %s' % (
member['uid'][0].ljust(12),
member['cn'][0].ljust(30),
member.get('program', [''])[0]
)

View File

@ -1,38 +0,0 @@
from ceo import members, terms, mysql
class MySQL:
help = '''
mysql create <username>
Creates a mysql database for a user.
'''
def main(self, args):
if len(args) != 2 or args[0] != 'create':
print self.help
return
username = args[1]
problem = None
try:
password = mysql.create_mysql(username)
try:
mysql.write_mysql_info(username, password)
helpfiletext = "Settings written to ~%s/ceo-mysql-info." % username
except (KeyError, IOError, OSError), e:
helpfiletext = "An error occured writing the settings file: %s" % e
print "MySQL database created"
print ("Connection Information: \n"
"\n"
"Database: %s\n"
"Username: %s\n"
"Hostname: localhost\n"
"Password: %s\n"
"\n"
"%s\n"
% (username, username, password, helpfiletext))
except mysql.MySQLException, e:
print "Failed to create MySQL database"
print
print "We failed to create the database. The error was:\n\n%s" % e

View File

@ -1,49 +0,0 @@
import ldap, sys, termios
from ceo import members, uwldap, ldapi
blacklist = ('orphaned', 'expired')
class UpdatePrograms:
help = '''
updateprograms
Interactively updates the program field for an account by querying uwdir.
'''
def main(self, args):
mlist = members.list_all().items()
uwl = ldap.initialize(uwldap.uri())
fd = sys.stdin.fileno()
for (dn, member) in mlist:
uid = member['uid'][0]
user = uwl.search_s(uwldap.base(), ldap.SCOPE_SUBTREE,
'(uid=%s)' % ldapi.escape(uid))
if len(user) == 0:
continue
user = user[0][1]
oldprog = member.get('program', [''])[0]
newprog = user.get('ou', [''])[0]
if oldprog == newprog or newprog == '' or newprog.lower() in blacklist:
continue
sys.stdout.write("%s: '%s' => '%s'? (y/n) " % (uid, oldprog, newprog))
new = old = termios.tcgetattr(fd)
new[3] = new[3] & ~termios.ICANON
try:
termios.tcsetattr(fd, termios.TCSANOW, new)
try:
if sys.stdin.read(1) != 'y':
continue
except KeyboardInterrupt:
return ''
finally:
print ''
termios.tcsetattr(fd, termios.TCSANOW, old)
old = new = {}
if oldprog != '':
old = {'program': [oldprog]}
if newprog != '':
new = {'program': [newprog]}
mlist = ldapi.make_modlist(old, new)
# TODO: don't use members.ld directly
#if newprog != '':
# members.set_program(uid, newprog)
members.ld.modify_s(dn, mlist)

View File

@ -1,13 +0,0 @@
"""
Exceptions Module
This module provides some simple but generally useful exception classes.
"""
class InvalidArgument(Exception):
"""Exception class for bad argument values."""
def __init__(self, argname, argval, explanation):
Exception.__init__(self)
self.argname, self.argval, self.explanation = argname, argval, explanation
def __str__(self):
return 'Bad argument value "%s" for %s: %s' % (self.argval, self.argname, self.explanation)

24
ceo/krb_check.py Normal file
View File

@ -0,0 +1,24 @@
import subprocess
import gssapi
def krb_check():
"""
Spawns a `kinit` process if no credentials are available or the
credentials have expired.
Returns the principal string 'user@REALM'.
"""
for _ in range(2):
try:
creds = gssapi.Credentials(usage='initiate')
result = creds.inquire()
return str(result.name)
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
kinit()
raise Exception('could not acquire GSSAPI credentials')
def kinit():
subprocess.run(['kinit'], check=True)

View File

@ -1,148 +0,0 @@
"""
LDAP Utilities
This module makes use of python-ldap, a Python module with bindings
to libldap, OpenLDAP's native C client library.
"""
import ldap.modlist, os, pwd
from subprocess import Popen, PIPE
def connect_sasl(uri, mech, realm, password):
try:
# open the connection
ld = ldap.initialize(uri)
# authenticate
sasl = Sasl(mech, realm, password)
ld.sasl_interactive_bind_s('', sasl)
except ldap.LOCAL_ERROR, e:
raise e
except:
print "Shit, something went wrong!"
return ld
def abslookup(ld, dn, objectclass=None):
# search for the specified dn
try:
if objectclass:
search_filter = '(objectclass=%s)' % escape(objectclass)
matches = ld.search_s(dn, ldap.SCOPE_BASE, search_filter)
else:
matches = ld.search_s(dn, ldap.SCOPE_BASE)
except ldap.NO_SUCH_OBJECT:
return None
# dn was found, but didn't match the objectclass filter
if len(matches) < 1:
return None
# return the attributes of the single successful match
match = matches[0]
match_dn, match_attributes = match
return match_attributes
def lookup(ld, rdntype, rdnval, base, objectclass=None):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
return abslookup(ld, dn, objectclass)
def search(ld, base, search_filter, params=[], scope=ldap.SCOPE_SUBTREE, attrlist=None, attrsonly=0):
real_filter = search_filter % tuple(escape(x) for x in params)
# search for entries that match the filter
matches = ld.search_s(base, scope, real_filter, attrlist, attrsonly)
return matches
def modify(ld, rdntype, rdnval, base, mlist):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
ld.modify_s(dn, mlist)
def modify_attrs(ld, rdntype, rdnval, base, old, attrs):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
# build list of modifications to make
changes = ldap.modlist.modifyModlist(old, attrs)
# apply changes
ld.modify_s(dn, changes)
def modify_diff(ld, rdntype, rdnval, base, old, new):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
# build list of modifications to make
changes = make_modlist(old, new)
# apply changes
ld.modify_s(dn, changes)
def escape(value):
"""
Escapes special characters in a value so that it may be safely inserted
into an LDAP search filter.
"""
value = str(value)
value = value.replace('\\', '\\5c').replace('*', '\\2a')
value = value.replace('(', '\\28').replace(')', '\\29')
value = value.replace('\x00', '\\00')
return value
def make_modlist(old, new):
keys = set(old.keys()).union(set(new))
mlist = []
for key in keys:
if key in old and not key in new:
mlist.append((ldap.MOD_DELETE, key, list(set(old[key]))))
elif key in new and not key in old:
mlist.append((ldap.MOD_ADD, key, list(set(new[key]))))
else:
to_add = list(set(new[key]) - set(old[key]))
if len(to_add) > 0:
mlist.append((ldap.MOD_ADD, key, to_add))
to_del = list(set(old[key]) - set(new[key]))
if len(to_del) > 0:
mlist.append((ldap.MOD_DELETE, key, to_del))
return mlist
def format_ldaperror(ex):
desc = ex[0].get('desc', '')
info = ex[0].get('info', '')
if desc and info:
return "%s: %s" % (desc, info)
elif desc:
return desc
else:
return str(ex)
class Sasl:
def __init__(self, mech, realm, password):
self.mech = mech
self.realm = realm
if mech == 'GSSAPI' and password is not None:
userid = pwd.getpwuid(os.getuid()).pw_name
kinit = '/usr/bin/kinit'
kinit_args = [ kinit, '%s@%s' % (userid, realm) ]
kinit = Popen(kinit_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
kinit.stdin.write('%s\n' % password)
kinit.wait()
def callback(self, id, challenge, prompt, defresult):
return ''

View File

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

View File

@ -1,54 +0,0 @@
import os, re, subprocess, ldap, socket, pwd
from ceo import conf, ldapi, terms, remote, ceo_pb2
from ceo.excep import InvalidArgument
class MySQLException(Exception):
pass
def write_mysql_info(username, password):
homedir = pwd.getpwnam(username).pw_dir
password_file = '%s/ceo-mysql-info' % homedir
if os.path.exists(password_file):
os.rename(password_file, password_file + '.old')
fd = os.open(password_file, os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0660)
fh = os.fdopen(fd, 'w')
fh.write("""MySQL Database Information for %(username)s
Your new MySQL database was created. To connect, use
the following options:
Database: %(username)s
Username: %(username)s
Password: %(password)s
Hostname: localhost
The command to connect using the MySQL command-line client is
mysql %(username)s -u %(username)s -p
If you prefer a GUI you can use phpmyadmin at
http://csclub.uwaterloo.ca/phpmyadmin
This database is only accessible from caffeine.
""" % { 'username': username, 'password': password })
fh.close()
def create_mysql(username):
try:
request = ceo_pb2.AddMySQLUser()
request.username = username
out = remote.run_remote('mysql', request.SerializeToString())
response = ceo_pb2.AddMySQLUserResponse()
response.ParseFromString(out)
if any(message.status != 0 for message in response.messages):
raise MySQLException('\n'.join(message.message for message in response.messages))
return response.password
except remote.RemoteException, e:
raise MySQLException(e)

27
ceo/operation_strings.py Normal file
View File

@ -0,0 +1,27 @@
# These descriptions are printed to the console while a transaction
# is performed, in real time.
descriptions = {
'add_user_to_ldap': 'Add user to LDAP',
'add_group_to_ldap': 'Add group to LDAP',
'add_user_to_kerberos': 'Add user to Kerberos',
'create_home_dir': 'Create home directory',
'set_forwarding_addresses': 'Set forwarding addresses',
'send_welcome_message': 'Send welcome message',
'subscribe_to_mailing_list': 'Subscribe to mailing list',
'announce_new_user': 'Announce new user to mailing list',
'replace_login_shell': 'Replace login shell',
'replace_forwarding_addresses': 'Replace forwarding addresses',
'remove_user_from_ldap': 'Remove user from LDAP',
'remove_group_from_ldap': 'Remove group from LDAP',
'remove_user_from_kerberos': 'Remove user from Kerberos',
'delete_home_dir': 'Delete home directory',
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list',
'add_sudo_role': 'Add sudo role to LDAP',
'add_user_to_group': 'Add user to group',
'add_user_to_auxiliary_groups': 'Add user to auxiliary groups',
'subscribe_user_to_auxiliary_mailing_lists': 'Subscribe user to auxiliary mailing lists',
'remove_user_from_group': 'Remove user from group',
'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups',
'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists',
'remove_sudo_role': 'Remove sudo role from LDAP',
}

View File

@ -1,24 +0,0 @@
import os, syslog, grp
def response_message(response, status, message):
if status:
priority = syslog.LOG_ERR
else:
priority = syslog.LOG_INFO
syslog.syslog(priority, message)
msg = response.messages.add()
msg.status = status
msg.message = message
return status
def get_ceo_user():
user = os.environ.get('CEO_USER')
if not user:
raise Exception("environment variable CEO_USER not set");
return user
def check_group(user, group):
try:
return user in grp.getgrnam(group).gr_mem
except KeyError:
return False

View File

@ -1,155 +0,0 @@
#!/usr/bin/python
from xml.dom import minidom, Node
import urllib
import time
import datetime
import hashlib
import base64
import hmac
class PyMazonError(Exception):
"""Holds information about an error that occured during a pymazon request"""
def __init__(self, messages):
self.__message = '\n'.join(messages)
def __get_message(self):
return self.__message
def __str__(self):
return repr(self.__message)
message = property(fget=__get_message)
class PyMazonBook:
"""Stores information about a book retrieved via PyMazon."""
def __init__(self, title, authors, publisher, year, isbn10, isbn13, edition):
self.__title = title
self.__authors = authors
self.__publisher = publisher
self.__year = year
self.__isbn10 = isbn10
self.__isbn13 = isbn13
self.__edition = edition
def __str__(self):
return 'Title: ' + self.title + '\n' + \
'Author(s): ' + ', '.join(self.authors) + '\n' \
'Publisher: ' + self.publisher + '\n' + \
'Year: ' + self.year + '\n' + \
'ISBN-10: ' + self.isbn10 + '\n' + \
'ISBN-13: ' + self.isbn13 + '\n' + \
'Edition: ' + self.edition
def __get_title(self):
return self.__title
def __get_authors(self):
return self.__authors
def __get_publisher(self):
return self.__publisher
def __get_year(self):
return self.__year
def __get_isbn10(self):
return self.__isbn10
def __get_isbn13(self):
return self.__isbn13
def __get_edition(self):
return self.__edition
title = property(fget=__get_title)
authors = property(fget=__get_authors)
publisher = property(fget=__get_publisher)
year = property(fget=__get_year)
isbn10 = property(fget=__get_isbn10)
isbn13 = property(fget=__get_isbn13)
edition = property(fget=__get_edition)
class PyMazon:
"""A method of looking up book information on Amazon."""
def __init__(self, accesskey, secretkey):
self.__key = accesskey
self.__secret = secretkey
self.__last_query_time = 0
def __form_request(self, isbn):
content = {}
dstamp = datetime.datetime.utcfromtimestamp(time.time())
content['Timestamp'] = dstamp.strftime('%Y-%m-%dT%H:%M:%S.000Z')
content['Service'] = 'AWSECommerceService'
content['Version'] = '2008-08-19'
content['Operation'] = 'ItemLookup'
content['ResponseGroup'] = 'ItemAttributes'
content['IdType'] = 'ISBN'
content['SearchIndex'] = 'Books'
content['ItemId'] = isbn
content['AWSAccessKeyId'] = self.__key
URI_String = []
for key, value in sorted(content.items()):
URI_String.append('%s=%s' % (key, urllib.quote(value)))
req = '&'.join(URI_String)
to_sign_req = 'GET\necs.amazonaws.com\n/onca/xml\n' + req
h = hmac.new(self.__secret, to_sign_req, hashlib.sha256)
sig = base64.b64encode(h.digest())
req += '&Signature=%s' % urllib.quote(sig)
return 'http://ecs.amazonaws.com/onca/xml?' + req
def __elements_text(self, element, name):
result = []
matching = element.getElementsByTagName(name)
for match in matching:
if len(match.childNodes) != 1:
continue
child = match.firstChild
if child.nodeType != Node.TEXT_NODE:
continue
result.append(child.nodeValue.strip())
return result
def __format_errors(self, errors):
error_list = []
for error in errors:
error_list.extend(self.__elements_text(error, 'Message'))
return error_list
def __extract_single(self, element, name):
matches = self.__elements_text(element, name)
if len(matches) == 0:
return ''
return matches[0]
def lookup(self, isbn):
file = urllib.urlretrieve(self.__form_request(isbn))[0]
xmldoc = minidom.parse(file)
cur_time = time.time()
while cur_time - self.__last_query_time < 1.0:
sleep(cur_time - self.__last_query_time)
cur_time = time.time()
self.__last_query_time = cur_time
errors = xmldoc.getElementsByTagName('Errors')
if len(errors) != 0:
raise PyMazonError, self.__format_errors(errors)
title = self.__extract_single(xmldoc, 'Title')
authors = self.__elements_text(xmldoc, 'Author')
publisher = self.__extract_single(xmldoc, 'Publisher')
year = self.__extract_single(xmldoc, 'PublicationDate')[0:4]
isbn10 = self.__extract_single(xmldoc, 'ISBN')
isbn13 = self.__extract_single(xmldoc, 'EAN')
edition = self.__extract_single(xmldoc, 'Edition')
return PyMazonBook(title, authors, publisher, year, isbn10, isbn13, edition)

View File

@ -1,18 +0,0 @@
import os
import subprocess
class RemoteException(Exception):
"""Exception class for bad argument values."""
def __init__(self, status, stdout, stderr):
self.status, self.stdout, self.stderr = status, stdout, stderr
def __str__(self):
return 'Error executing ceoc (%d)\n\n%s' % (self.status, self.stderr)
def run_remote(op, data):
ceoc = '%s/ceoc' % os.environ.get('CEO_LIB_DIR', '/usr/lib/ceod')
addmember = subprocess.Popen([ceoc, op], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = addmember.communicate(data)
status = addmember.wait()
if status:
raise RemoteException(status, out, err)
return out

View File

@ -1,254 +0,0 @@
"""
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 validate(term):
"""
Determines whether a term is well-formed.
Parameters:
term - the term string
Returns: whether the term is valid (boolean)
Example: validate("f2006") -> True
"""
regex = '^[wsf][0-9]{4}$'
return re.match(regex, term) is not 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 validate(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__':
from ceo.test import test, assert_equal, success
test(parse); assert_equal(110, parse('f2006')); success()
test(generate); assert_equal('f2006', generate(110)); success()
test(next); assert_equal('w2007', next('f2006')); success()
test(previous); assert_equal('s2006', previous('f2006')); success()
test(delta); assert_equal(1, delta('f2006', 'w2007')); success()
test(compare); assert_equal(-1, compare('f2006', 'w2007')); success()
test(add); assert_equal('w2010', add('f2006', delta('f2006', 'w2010'))); success()
test(interval); assert_equal(['f2006', 'w2007', 's2007'], interval('f2006', 3)); success()
test(from_timestamp); assert_equal('f2006', from_timestamp(1166135779)); success()
test(current); assert_equal(True, parse( current() ) >= 110 ); success()
test(next_unregistered)
assert_equal( next(current()), next_unregistered([ current() ]))
assert_equal( current(), next_unregistered([]))
assert_equal( current(), next_unregistered([ previous(current()) ]))
assert_equal( current(), next_unregistered([ add(current(), -2) ]))
success()

View File

@ -1,42 +0,0 @@
"""
Common Test Routines
This module contains helpful functions called by each module's test suite.
"""
from types import FunctionType, MethodType, ClassType, TypeType
class TestException(Exception):
"""Exception class for test failures."""
def test(subject):
"""Print a test message."""
if type(subject) in (MethodType, FunctionType, ClassType, TypeType):
print "testing %s()..." % subject.__name__,
else:
print "testing %s..." % subject,
def success():
"""Print a success message."""
print "pass."
def assert_equal(expected, actual):
if expected != actual:
message = "Expected (%s)\nWas (%s)" % (repr(expected), repr(actual))
fail(message)
def fail(message):
print "failed!"
raise TestException("Test failed:\n%s" % message)
def negative(call, args, excep, message):
try:
call(*args)
fail(message)
except excep:
pass

View File

@ -1 +0,0 @@
"""Urwid User Interface"""

View File

@ -1,84 +0,0 @@
import urwid
from ceo import members, mysql
from ceo.urwid import search
from ceo.urwid.widgets import *
from ceo.urwid.window import *
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text("MySQL databases"),
urwid.Divider(),
urwid.Text("Members and hosted clubs may have one MySQL database each. You may "
"create a database for an account if: \n"
"\n"
"- It is your personal account,\n"
"- It is a club account, and you are in the club group, or\n"
"- You are on the CSC systems committee\n"
"\n"
"You may also use this to reset your database password."
)
]
def focusable(self):
return False
class UserPage(WizardPanel):
def init_widgets(self):
self.userid = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"Username: ")
self.widgets = [
urwid.Text("Member Information"),
urwid.Divider(),
urwid.Text("Enter the user which will own the new database."),
urwid.Divider(),
self.userid,
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
self.state['member'] = None
if self.state['userid']:
self.state['member'] = members.get(self.userid.get_edit_text())
if not self.state['member']:
set_status("Member not found")
self.focus_widget(self.userid)
return True
class EndPage(WizardPanel):
def init_widgets(self):
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def activate(self):
problem = None
try:
password = mysql.create_mysql(self.state['userid'])
try:
mysql.write_mysql_info(self.state['userid'], password)
helpfiletext = "Settings written to ~%s/ceo-mysql-info." % self.state['userid']
except (KeyError, IOError, OSError), e:
helpfiletext = "An error occured writing the settings file: %s" % e
self.headtext.set_text("MySQL database created")
self.midtext.set_text("Connection Information: \n"
"\n"
"Database: %s\n"
"Username: %s\n"
"Hostname: localhost\n"
"Password: %s\n"
"\n"
"%s\n"
% (self.state['userid'], self.state['userid'], password, helpfiletext))
except mysql.MySQLException, e:
self.headtext.set_text("Failed to create MySQL database")
self.midtext.set_text("We failed to create the database. The error was:\n\n%s" % e)
def check(self):
pop_window()

View File

@ -1,135 +0,0 @@
import urwid
from ceo import members
from ceo.urwid import search
from ceo.urwid.widgets import *
from ceo.urwid.window import *
def change_group_member(data):
push_wizard("%s %s Member" % (data["action"], data["name"]), [
(ChangeMember, data),
EndPage,
])
def list_group_members(data):
mlist = members.list_group( data["group"] ).values()
search.member_list( mlist )
def group_members(data):
add_data = data.copy()
add_data['action'] = 'Add'
remove_data = data.copy()
remove_data['action'] = 'Remove'
menu = make_menu([
("Add %s member" % data["name"].lower(),
change_group_member, add_data),
("Remove %s member" % data["name"].lower(),
change_group_member, remove_data),
("List %s members" % data["name"].lower(), list_group_members, data),
("Back", raise_back, None),
])
push_window(menu, "Manage %s" % data["name"])
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Managing Club or Group" ),
urwid.Divider(),
urwid.Text( "Adding a member to a club will also grant them "
"access to the club's files and allow them to "
"become_club."
"\n\n")
]
def focusable(self):
return False
class InfoPage(WizardPanel):
def init_widgets(self):
self.group = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"Club or Group: ")
self.widgets = [
urwid.Text( "Club or Group Information"),
urwid.Divider(),
self.group,
]
def check(self):
group = self.group.get_edit_text()
# check if group is valid
if not group or not members.get_group(group):
set_status("Group not found")
self.focus_widget(self.group)
return True
data = {
"name" : group,
"group" : group,
"groups" : [],
}
# Office Staff and Syscom get added to more groups
if group == "syscom":
data["name"] = "Systems Committee"
data["groups"] = [ "office", "staff", "adm", "src" ]
elif group == "office":
data["name"] = "Office Staff"
data["groups"] = [ "cdrom", "audio", "video", "www" ]
group_members(data)
class ChangeMember(WizardPanel):
def __init__(self, state, data):
state['data'] = data
WizardPanel.__init__(self, state)
def init_widgets(self):
self.userid = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"Username: ")
data = self.state['data']
self.widgets = [
urwid.Text( "%s %s Member" % (data['action'], data['name']) ),
urwid.Divider(),
self.userid,
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
if self.state['userid']:
self.state['member'] = members.get(self.userid.get_edit_text())
if not self.state['member']:
set_status("Member not found")
self.focus_widget(self.userid)
return True
clear_status()
class EndPage(WizardPanel):
def init_widgets(self):
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def check(self):
pop_window()
def activate(self):
data = self.state['data']
action = data['action'].lower()
failed = []
for group in data['groups'] + [data['group']]:
try:
members.change_group_member(action, group, self.state['userid'])
except ldap.LDAPError:
failed.append(group)
if len(failed) == 0:
self.headtext.set_text("%s succeeded" % data['action'])
self.midtext.set_text("Congratulations, the group modification "
"has succeeded.")
else:
self.headtext.set_text("%s Results" % data['action'])
self.midtext.set_text("Failed to %s member to %s for the "
"following groups: %s. This may indicate an attempt to add a "
"duplicate group member or to delete a member that was not in "
"the group." % (data['action'].lower(), data['name'],
', '.join(failed)))

View File

@ -1,47 +0,0 @@
import urwid
from ceo.urwid.widgets import *
from ceo.urwid.window import *
from ceo import terms
class InfoPage(WizardPanel):
def init_widgets(self):
self.userid = urwid.Text("")
self.name = urwid.Text("")
self.terms = urwid.Text("")
self.nmterms = urwid.Text("")
self.program = urwid.Text("")
self.widgets = [
urwid.Text( "Member Details" ),
urwid.Divider(),
self.name,
self.userid,
self.program,
urwid.Divider(),
self.terms,
self.nmterms,
]
def focusable(self):
return False
def activate(self):
member = self.state.get('member', {})
name = member.get('cn', [''])[0]
userid = self.state['userid']
program = member.get('program', [''])[0]
shell = member.get('loginShell', [''])[0]
mterms = member.get('term', [])
nmterms = member.get('nonMemberTerm', [])
mterms.sort(terms.compare)
nmterms.sort(terms.compare)
self.name.set_text("Name: %s" % name)
self.userid.set_text("User: %s" % userid)
self.program.set_text("Program: %s" % program)
self.program.set_text("Shell: %s" % shell)
if terms:
self.terms.set_text("Terms: %s" % ", ".join(mterms))
if nmterms:
self.nmterms.set_text("Rep Terms: %s" % ", ".join(nmterms))
def check(self):
pop_window()

View File

@ -1,8 +0,0 @@
import os
from ceo.urwid.window import *
def library(data):
os.system("librarian")
ui.stop()
ui.start()

View File

@ -1,192 +0,0 @@
import os, grp, pwd, sys, random, urwid.curses_display
from ceo.urwid.widgets import *
from ceo.urwid.window import *
from ceo.urwid import newmember, renew, info, search, positions, groups, \
shell, library, databases
def program_name():
cwords = [ "CSC" ] * 20 + [ "Club" ] * 10 + [ "Campus" ] * 5 + \
[ "Communist", "Canadian", "Celestial", "Cryptographic", "Calum's",
"Canonical", "Capitalist", "Catastrophic", "Ceremonial", "Chaotic", "Civic",
"City", "County", "Caffeinated" ]
ewords = [ "Embellished", "Ergonomic", "Electric", "Eccentric", "European", "Economic",
"Evil", "Egotistical", "Elliptic", "Emasculating", "Embalming",
"Embryonic", "Emigrant", "Emissary's", "Emoting", "Employment", "Emulated",
"Enabling", "Enamoring", "Encapsulated", "Enchanted", "Encoded", "Encrypted",
"Encumbered", "Endemic", "Enhanced", "Enigmatic", "Enlightened", "Enormous",
"Enrollment", "Enshrouded", "Ephemeral", "Epidemic", "Episodic", "Epsilon",
"Equitable", "Equestrian", "Equilateral", "Erroneous", "Erratic",
"Espresso", "Essential", "Estate", "Esteemed", "Eternal", "Ethical", "Eucalyptus",
"Euphemistic", "Evangelist", "Evasive", "Everyday", "Evidence", "Eviction", "Evildoer's",
"Evolution", "Exacerbation", "Exalted", "Examiner's", "Excise", "Exciting", "Exclusion",
"Exec", "Executioner's", "Exile", "Existential", "Expedient", "Expert", "Expletive",
"Exploiter's", "Explosive", "Exponential", "Exposing", "Extortion", "Extraction",
"Extraneous", "Extravaganza", "Extreme", "Extraterrestrial", "Extremist", "Eerie" ]
owords = [ "Office" ] * 50 + [ "Outhouse", "Outpost" ]
cword = random.choice(cwords)
eword = random.choice(ewords)
oword = random.choice(owords)
return "%s %s %s" % (cword, eword, oword)
def new_member(*args, **kwargs):
push_wizard("New Member", [
newmember.IntroPage,
newmember.InfoPage,
newmember.NumberOfTermsPage,
newmember.SignPage,
newmember.PassPage,
newmember.EndPage,
], (60, 15))
def new_club(*args, **kwargs):
push_wizard("New Club Account", [
newmember.ClubIntroPage,
newmember.ClubInfoPage,
(newmember.EndPage, "club"),
], (60, 15))
def new_club_user(*args, **kwargs):
push_wizard("New Club Rep Account", [
newmember.ClubUserIntroPage,
newmember.ClubNoPayPage,
newmember.InfoPage,
newmember.NumberOfTermsPage,
newmember.SignPage,
newmember.PassPage,
(newmember.EndPage, "clubuser"),
], (60, 15))
def manage_group(*args, **kwargs):
push_wizard("Manage Club or Group Members", [
groups.IntroPage,
groups.InfoPage,
], (60, 15))
def renew_member(*args, **kwargs):
push_wizard("Renew Membership", [
renew.IntroPage,
renew.UserPage,
renew.EmailPage,
renew.EmailDonePage,
renew.TermPage,
renew.PayPage,
renew.EndPage,
], (60, 15))
def renew_club_user(*args, **kwargs):
push_wizard("Renew Club Rep Account", [
renew.ClubUserIntroPage,
newmember.ClubNoPayPage,
renew.UserPage,
renew.EmailPage,
renew.EmailDonePage,
(renew.TermPage, "clubuser"),
(renew.EndPage, "clubuser"),
], (60, 15))
def display_member(data):
push_wizard("Display Member", [
renew.UserPage,
info.InfoPage,
], (60, 15))
def search_members(data):
menu = make_menu([
("Members by term", search_term, None),
("Members by name", search_name, None),
("Members by group", search_group, None),
("Back", raise_back, None),
])
push_window(menu, "Search Members")
def search_name(data):
push_wizard("By Name", [ search.NamePage ])
def search_term(data):
push_wizard("By Term", [ search.TermPage ])
def search_group(data):
push_wizard("By Group", [ search.GroupPage ])
def manage_positions(data):
push_wizard("Manage Positions", [
positions.IntroPage,
positions.InfoPage,
positions.EndPage,
], (50, 15))
def change_shell(data):
push_wizard("Change Shell", [
shell.IntroPage,
shell.YouPage,
shell.ShellPage,
shell.EndPage
], (50, 20))
def create_mysql_db(data):
push_wizard("Create MySQL database", [
databases.IntroPage,
databases.UserPage,
databases.EndPage,
], (60, 15))
def check_group(group):
try:
me = pwd.getpwuid(os.getuid()).pw_name
return me in grp.getgrnam(group).gr_mem
except KeyError:
pass
def top_menu():
office_only = [
("New Member", new_member, None),
("New Club Rep", new_club_user, None),
("Renew Membership", renew_member, None),
("Renew Club Rep", renew_club_user, None),
("New Club", new_club, None),
("Library", library.library, None),
]
syscom_only = [
("Manage Club or Group Members", manage_group, None),
("Manage Positions", manage_positions, None),
]
unrestricted = [
("Display Member", display_member, None),
("Search Members", search_members, None),
("Change Shell", change_shell, None),
("Create MySQL database", create_mysql_db, None),
]
footer = [
("Exit", raise_abort, None),
]
menu = None
# reorder the menu for convenience
if not check_group('office') and not check_group('syscom'):
menu = labelled_menu([
('Unrestricted', unrestricted),
('Office Staff', office_only),
('Systems Committee', syscom_only),
(None, footer)
])
else:
menu = labelled_menu([
('Office Staff', office_only),
('Unrestricted', unrestricted),
('Systems Committee', syscom_only),
(None, footer)
])
return menu
def run():
push_window(top_menu(), program_name())
event_loop(ui)
def start():
ui.run_wrapper( run )
if __name__ == '__main__':
start()

View File

@ -1,267 +0,0 @@
import ldap, urwid #, re
from ceo import members, terms, remote, uwldap
from ceo.urwid.widgets import *
from ceo.urwid.window import *
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Joining the Computer Science Club" ),
urwid.Divider(),
urwid.Text( "CSC membership is $2.00 per term. Please ensure "
"the fee is deposited into the cup before continuing." ),
]
def focusable(self):
return False
class ClubIntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Club Accounts" ),
urwid.Divider(),
urwid.Text( "We provide other UW clubs accounts for email and "
"web hosting, free of charge. Like members, clubs "
"get web hosting at %s. We can also arrange for "
"uwaterloo.ca subdomains; please instruct the club "
"representative to contact the systems committee "
"for more information. Club accounts do not have "
"passwords, and exist primarily to own club data. "
% "http://csclub.uwaterloo.ca/~clubid/" ),
]
def focusable(self):
return False
class ClubNoPayPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Club representative accounts are free. Please ensure "
"that no money was paid for this account." ),
]
def focusable(self):
return False
class ClubUserIntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Club Rep Account" ),
urwid.Divider(),
urwid.Text( "This is for people who need access to a club account, "
"but are not currently interested in full CSC membership. "
"Registering a user in this way grants one term of free "
"access to our machines, without any other membership "
"privileges (they can't vote, hold office, etc). If such "
"a user later decides to join, use the Renew Membership "
"option." ),
]
def focusable(self):
return False
class InfoPage(WizardPanel):
def init_widgets(self):
self.name = SingleEdit("Full name: ")
self.program = SingleEdit("Program of Study: ")
self.email = SingleEdit("Email: ")
self.userid = LdapFilterWordEdit(uwldap.uri(), uwldap.base(), 'uid',
{'cn':self.name, 'ou':self.program}, "Username: ")
self.widgets = [
urwid.Text( "Member Information" ),
urwid.Divider(),
self.userid,
self.name,
self.program,
self.email,
urwid.Divider(),
urwid.Text("Notes:"),
urwid.Text("- Make sure to check ID (watcard, drivers license)"),
urwid.Text("- Make sure to use UW userids for current students\n (we check uwldap to see if you are a full member)"),
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
self.state['name'] = self.name.get_edit_text()
self.state['program'] = self.program.get_edit_text()
self.state['email'] = self.email.get_edit_text()
if len( self.state['userid'] ) < 2:
self.focus_widget( self.userid )
set_status("Username is too short")
return True
elif len( self.state['name'] ) < 4:
self.focus_widget( self.name )
set_status("Name is too short")
return True
elif self.state['userid'] == self.state['name']:
self.focus_widget(self.name)
set_status("Name matches username")
return True
clear_status()
class ClubInfoPage(WizardPanel):
def init_widgets(self):
self.userid = WordEdit("Username: ")
self.name = SingleEdit("Club Name: ")
self.widgets = [
urwid.Text( "Club Information" ),
urwid.Divider(),
self.userid,
self.name,
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
self.state['name'] = self.name.get_edit_text()
if len( self.state['userid'] ) < 3:
self.focus_widget( self.userid )
set_status("Username is too short")
return True
elif len( self.state['name'] ) < 4:
self.focus_widget( self.name )
set_status("Name is too short")
return True
elif self.state['userid'] == self.state['name']:
self.focus_widget(self.name)
set_status("Name matches username")
return True
clear_status()
class NumberOfTermsPage(WizardPanel):
def init_widgets(self):
self.count = SingleIntEdit("Count: ")
self.widgets = [
urwid.Text("Number of Terms"),
urwid.Divider(),
urwid.Text("The member will be initially registered for this many "
"consecutive terms.\n"),
self.count
]
def activate(self):
self.count.set_edit_text("1")
self.focus_widget(self.count)
def check(self):
self.state['terms'] = terms.interval(terms.current(), self.count.value())
if len(self.state['terms']) == 0:
self.focus_widget(self.count)
set_status("Registering for zero terms?")
return True
clear_status()
class SignPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Machine Usage Policy" ),
urwid.Divider(),
urwid.Text( "Ensure the new member has signed the "
"Machine Usage Policy. Accounts of users who have not "
"signed will be suspended if discovered." ),
]
def focusable(self):
return False
class PassPage(WizardPanel):
def init_widgets(self):
self.password = PassEdit("Password: ")
self.pwcheck = PassEdit("Re-enter: ")
self.widgets = [
urwid.Text( "Member Password" ),
urwid.Divider(),
self.password,
self.pwcheck,
]
def focus_widget(self, widget):
self.box.set_focus( self.widgets.index( widget ) )
def clear_password(self):
self.focus_widget( self.password )
self.password.set_edit_text("")
self.pwcheck.set_edit_text("")
def check(self):
self.state['password'] = self.password.get_edit_text()
pwcheck = self.pwcheck.get_edit_text()
if self.state['password'] != pwcheck:
self.clear_password()
set_status("Passwords do not match")
return True
elif len(self.state['password']) < 5:
self.clear_password()
set_status("Password is too short")
return True
clear_status()
class EndPage(WizardPanel):
def __init__(self, state, utype='member'):
self.utype = utype
WizardPanel.__init__(self, state)
def init_widgets(self):
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def check(self):
pop_window()
def activate(self):
self.headtext.set_text("Adding %s" % self.state['userid'])
self.midtext.set_text("Please be patient while the user is added. "
"If more than a few seconds pass, check for a "
"phase variance and try inverting the polarity.")
set_status("Contacting the gibson...")
redraw()
problem = None
try:
if self.utype == 'member':
members.create_member(
self.state['userid'],
self.state['password'],
self.state['name'],
self.state['program'],
self.state['email'])
members.register(self.state['userid'], self.state['terms'])
mailman_result = members.subscribe_to_mailing_list(self.state['userid'])
if mailman_result != 'None':
problem = mailman_result
elif self.utype == 'clubuser':
members.create_member(
self.state['userid'],
self.state['password'],
self.state['name'],
self.state['program'],
self.state['email'],
club_rep=True)
members.register_nonmember(self.state['userid'], self.state['terms'])
elif self.utype == 'club':
members.create_club(self.state['userid'], self.state['name'])
else:
raise Exception("Internal Error")
except members.InvalidArgument, e:
problem = str(e)
except ldap.LDAPError, e:
problem = str(e)
except members.MemberException, e:
problem = str(e)
except remote.RemoteException, e:
problem = str(e)
clear_status()
if problem:
self.headtext.set_text("Failures Occured Adding User")
self.midtext.set_text("The error was:\n\n%s\n\nThe account may be partially added "
"and you may or may not be able to log in. Or perhaps you are not office staff. "
"If this was not expected please contact systems committee." % problem)
return
else:
set_status("Strombola Delivers")
self.headtext.set_text("User Added")
self.midtext.set_text("Congratulations, %s has been added "
"successfully. You should also rebuild the website in "
"order to update the memberlist."
% self.state['userid'])

View File

@ -1,97 +0,0 @@
import urwid
from ceo import members
from ceo.urwid.widgets import *
from ceo.urwid.window import *
position_data = [
('president', 'President'),
('vice-president', 'Vice-president'),
('treasurer', 'Treasurer'),
('secretary', 'Secretary'),
('sysadmin', 'System Administrator'),
('cro', 'Chief Returning Officer'),
('librarian', 'Librarian'),
('imapd', 'Imapd'),
('webmaster', 'Web Master'),
('offsck', 'Office Manager'),
]
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Managing Positions" ),
urwid.Divider(),
urwid.Text( "Enter a username for each position. If a position is "
"held by multiple people, enter a comma-separated "
"list of usernames. If a position is held by nobody "
"leave the username blank." ),
]
def focusable(self):
return False
class InfoPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Positions" ),
urwid.Divider(),
]
positions = members.list_positions()
self.position_widgets = {}
for (position, text) in position_data:
widget = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"%s: " % text)
if position in positions:
widget.set_edit_text(','.join(positions[position].keys()))
else:
widget.set_edit_text('')
self.position_widgets[position] = widget
self.widgets.append(widget)
def parse(self, entry):
if len(entry) == 0:
return []
return entry.split(',')
def check(self):
self.state['positions'] = {}
for (position, widget) in self.position_widgets.iteritems():
self.state['positions'][position] = \
self.parse(widget.get_edit_text())
for p in self.state['positions'][position]:
if members.get(p) == None:
self.focus_widget(widget)
set_status( "Invalid username: '%s'" % p )
return True
clear_status()
class EndPage(WizardPanel):
def init_widgets(self):
old = members.list_positions()
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def activate(self):
failed = []
for (position, info) in self.state['positions'].iteritems():
try:
members.set_position(position, info)
except ldap.LDAPError:
failed.append(position)
if len(failed) == 0:
self.headtext.set_text("Positions Updated")
self.midtext.set_text("Congratulations, positions have been "
"updated. You should rebuild the website in order to update "
"the Positions page.")
else:
self.headtext.set_text("Positions Results")
self.midtext.set_text("Failed to update the following positions: "
"%s." % join(failed))
def check(self):
pop_window()

View File

@ -1,240 +0,0 @@
import urwid, ldap
from ceo import members, terms, ldapi
from ceo.urwid.widgets import *
from ceo.urwid.window import *
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Renewing Membership" ),
urwid.Divider(),
urwid.Text( "CSC membership is $2.00 per term. You may pre-register "
"for future terms if desired." )
]
def focusable(self):
return False
class ClubUserIntroPage(IntroPage):
def init_widgets(self):
self.widgets = [
urwid.Text( "Renewing Club User Account" ),
urwid.Divider(),
urwid.Text( "In order for clubs to maintain websites hosted by "
"the Computer Science Club, they need access to our "
"machines. We grant accounts to club users at no charge "
"in order to provide this access. Registering a user "
"in this way grants one term of free access to our "
"machines, without any other membership privileges "
"(they can't vote, hold office, etc). If such a user "
"decides to join, use the Renew Membership option." )
]
class UserPage(WizardPanel):
def init_widgets(self):
self.userid = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"Username: ")
self.widgets = [
urwid.Text( "Member Information" ),
urwid.Divider(),
self.userid,
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
self.state['member'] = None
if self.state['userid']:
self.state['member'] = members.get(self.userid.get_edit_text())
if not self.state['member']:
set_status("Member not found")
self.focus_widget(self.userid)
return True
class EmailPage(WizardPanel):
def init_widgets(self):
self.email = SingleEdit("Email: ")
self.widgets = [
urwid.Text( "Mail Forwarding" ),
urwid.Divider(),
urwid.Text("Please ensure the forwarding address for your CSC "
"email is up to date. You may leave this blank if you do not "
"want your CSC email forwarded, and intend to log in "
"regularly to check it."),
urwid.Divider(),
urwid.Text("Warning: Changing this overwrites ~/.forward"),
urwid.Divider(),
self.email,
]
def activate(self):
cfwd = members.current_email(self.state['userid'])
if cfwd:
self.state['old_forward'] = cfwd
else:
self.state['old_forward'] = ''
self.email.set_edit_text(self.state['old_forward'])
def check(self):
fwd = self.email.get_edit_text().strip().lower()
if fwd:
msg = members.check_email(fwd)
if msg:
set_status(msg)
return True
if fwd == '%s@csclub.uwaterloo.ca' % self.state['userid']:
set_status('You cannot forward your address to itself. Leave it blank to disable forwarding.')
return True
self.state['new_forward'] = fwd
class EmailDonePage(WizardPanel):
def init_widgets(self):
self.status = urwid.Text("")
self.widgets = [
urwid.Text("Mail Forwarding"),
urwid.Divider(),
self.status,
]
def focusable(self):
return False
def activate(self):
if self.state['old_forward'] == self.state['new_forward']:
if self.state['old_forward']:
self.status.set_text(
'You have chosen to leave your forwarding address '
'as %s. Make sure to check this email for updates '
'from the CSC.' % self.state['old_forward'])
else:
self.status.set_text(
'You have chosen not to set a forwarding address. '
'Please check your CSC email regularly (via IMAP, POP, or locally) '
'for updates from the CSC.'
'\n\n'
'Note: If you do have a ~/.forward, we were not able to read it or '
'it was not a single email address. Do not worry, we have left it '
'as is.')
else:
try:
msg = members.change_email(self.state['userid'], self.state['new_forward'])
if msg:
self.status.set_text("Errors occured updating your forwarding address:"
"\n\n%s" % msg)
else:
if self.state['new_forward']:
self.status.set_text(
'Your email forwarding address has been successfully set '
'to %s. Test it out by emailing %s@csclub.uwaterloo.ca and '
'making sure you receive it at your forwarding address.'
% (self.state['new_forward'], self.state['userid']))
else:
self.status.set_text(
'Your email forwarding address has been successfully cleared. '
'Please check your CSC email regularly (via IMAP, POP, or locally) '
'for updates from the CSC.')
except Exception, e:
self.status.set_text(
'An exception occured updating your email:\n\n%s' % e)
class TermPage(WizardPanel):
def __init__(self, state, utype='member'):
self.utype = utype
WizardPanel.__init__(self, state)
def init_widgets(self):
self.start = SingleEdit("Start: ")
self.count = SingleIntEdit("Count: ")
self.widgets = [
urwid.Text( "Terms to Register" ),
urwid.Divider(),
self.start,
self.count,
]
def activate(self):
if not self.start.get_edit_text():
self.terms = self.state['member'].get('term', [])
self.nmterms = self.state['member'].get('nonMemberTerm', [])
if self.utype == 'member':
self.start.set_edit_text( terms.next_unregistered( self.terms ) )
else:
self.start.set_edit_text( terms.next_unregistered( self.terms + self.nmterms ) )
self.count.set_edit_text( "1" )
def check(self):
try:
self.state['terms'] = terms.interval( self.start.get_edit_text(), self.count.value() )
except Exception, e:
self.focus_widget( self.start )
set_status( "Invalid start term" )
return True
for term in self.state['terms']:
if self.utype == 'member':
already = term in self.terms
else:
already = term in self.terms or term in self.nmterms
if already:
self.focus_widget( self.start )
set_status( "Already registered for " + term )
return True
if len(self.state['terms']) == 0:
self.focus_widget(self.count)
set_status( "Registering for zero terms?" )
return True
class PayPage(WizardPanel):
def init_widgets(self):
self.midtext = urwid.Text("")
self.widgets = [
urwid.Text("Membership Fee"),
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def activate(self):
regterms = self.state['terms']
plural = "term"
if len(self.state['terms']) > 1:
plural = "terms"
self.midtext.set_text("You are registering for %d %s, and owe the "
"Computer Science Club $%d.00 in membership fees. "
"Please deposit the money in the safe before "
"continuing. " % ( len(regterms), plural, len(regterms * 2)))
class EndPage(WizardPanel):
def __init__(self, state, utype='member'):
self.utype = utype
WizardPanel.__init__(self, state)
def init_widgets(self):
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def activate(self):
problem = None
try:
self.headtext.set_text("Registration Succeeded")
if self.utype == 'member':
members.register( self.state['userid'], self.state['terms'] )
self.midtext.set_text("The member has been registered for the following "
"terms: " + ", ".join(self.state['terms']) + ".")
else:
members.register_nonmember( self.state['userid'], self.state['terms'] )
self.midtext.set_text("The club user has been registered for the following "
"terms: " + ", ".join(self.state['terms']) + ".")
except ldap.LDAPError, e:
problem = ldapi.format_ldaperror(e)
except members.MemberException, e:
problem = str(e)
if problem:
self.headtext.set_text("Failed to Register")
self.midtext.set_text("You may refund any fees paid or retry. "
"The error was:\n\n%s" % problem)
def check(self):
pop_window()

View File

@ -1,83 +0,0 @@
import urwid
from ceo import members, terms
from ceo.urwid.widgets import *
from ceo.urwid.window import *
class TermPage(WizardPanel):
def init_widgets(self):
self.term = SingleEdit("Term: ")
self.widgets = [
urwid.Text( "Terms Members" ),
urwid.Divider(),
self.term,
]
def check(self):
try:
self.state['term'] = self.term.get_edit_text()
terms.parse( self.state['term'] )
except:
self.focus_widget( self.term )
set_status( "Invalid term" )
return True
mlist = members.list_term( self.state['term'] ).values()
pop_window()
member_list( mlist )
class NamePage(WizardPanel):
def init_widgets(self):
self.name = SingleEdit("Name: ")
self.widgets = [
urwid.Text( "Members by Name" ),
urwid.Divider(),
self.name,
]
def check(self):
self.state['name'] = self.name.get_edit_text()
if not self.state['name']:
self.focus_widget( self.name )
set_status( "Invalid name" )
return True
mlist = members.list_name( self.state['name'] ).values()
pop_window()
member_list( mlist )
class GroupPage(WizardPanel):
def init_widgets(self):
self.group = SingleEdit("Group: ")
self.widgets = [
urwid.Text( "Members by Group" ),
urwid.Divider(),
self.group,
]
def check(self):
self.state['group'] = self.group.get_edit_text()
if not self.state['group']:
self.focus_widget( self.group )
set_status( "Invalid group" )
return True
mlist = members.list_group( self.state['group'] ).values()
pop_window()
member_list( mlist )
def member_list(mlist):
mlist = list(mlist)
mlist.sort( lambda x, y: cmp(x['uid'], y['uid']) )
buf = ''
for member in mlist:
if 'uid' in member:
uid = member['uid'][0]
else:
uid = None
if 'program' in member:
program = member['program'][0]
else:
program = None
attrs = ( uid, member['cn'][0], program )
buf += "%10s %30s\n%41s\n\n" % attrs
set_status("Press escape to return to the menu")
push_window(urwid.ListBox([urwid.Text(buf)]))

View File

@ -1,95 +0,0 @@
import urwid, ldap, pwd, os
from ceo import members, terms, ldapi
from ceo.urwid.widgets import *
from ceo.urwid.window import *
class IntroPage(WizardPanel):
def init_widgets(self):
self.widgets = [
urwid.Text( "Changing Login Shell" ),
urwid.Divider(),
urwid.Text( "You can change your shell here. Request more shells "
"by emailing systems-committee." )
]
def focusable(self):
return False
class YouPage(WizardPanel):
def init_widgets(self):
you = pwd.getpwuid(os.getuid()).pw_name
self.userid = LdapWordEdit(csclub_uri, csclub_base, 'uid',
"Username: ", you)
self.widgets = [
urwid.Text( "Member Information" ),
urwid.Divider(),
self.userid,
]
def check(self):
self.state['userid'] = self.userid.get_edit_text()
self.state['member'] = None
if self.state['userid']:
self.state['member'] = members.get(self.userid.get_edit_text())
if not self.state['member']:
set_status("Member not found")
self.focus_widget(self.userid)
return True
class ShellPage(WizardPanel):
def init_widgets(self):
self.midtext = urwid.Text("")
self.widgets = [
urwid.Text("Choose a Shell"),
urwid.Divider(),
]
def set_shell(radio_button, new_state, shell):
if new_state:
self.state['shell'] = shell
radio_group = []
self.shells = members.get_shells()
self.shellw = [ urwid.RadioButton(radio_group, shell,
on_state_change=set_shell, user_data=shell)
for shell in self.shells ]
self.widgets.extend(self.shellw)
def set_shell(self, shell):
i = self.shells.index(shell)
self.shellw[i].set_state(True)
def focusable(self):
return True
def activate(self):
self.set_shell(self.state['member']['loginShell'][0])
class EndPage(WizardPanel):
def init_widgets(self):
self.headtext = urwid.Text("")
self.midtext = urwid.Text("")
self.widgets = [
self.headtext,
urwid.Divider(),
self.midtext,
]
def focusable(self):
return False
def activate(self):
problem = None
try:
user, shell = self.state['userid'], self.state['shell']
members.set_shell(user, shell)
self.headtext.set_text("Login Shell Changed")
self.midtext.set_text("The shell for %s has been changed to %s."
% (user, shell))
except ldap.LDAPError, e:
problem = ldapi.format_ldaperror(e)
except members.MemberException, e:
problem = str(e)
if problem:
self.headtext.set_text("Failed to Change Shell")
self.midtext.set_text("Perhaps you don't have permission to change %s's shell? "
"The error was:\n\n%s" % (user, problem))
def check(self):
pop_window()

View File

@ -1,247 +0,0 @@
import urwid, ldap, sys
from ceo.urwid.window import raise_back, push_window
import ceo.ldapi as ldapi
#Todo: kill ButtonText because no one uses it except one place and we can probably do that better anyway
csclub_uri = "ldap://ldap1.csclub.uwaterloo.ca/ ldap://ldap2.csclub.uwaterloo.ca"
csclub_base = "dc=csclub,dc=uwaterloo,dc=ca"
def make_menu(items):
items = [ urwid.AttrWrap( ButtonText( cb, data, txt ), 'menu', 'selected') for (txt, cb, data) in items ]
return ShortcutListBox(items)
def labelled_menu(itemses):
widgets = []
for label, items in itemses:
if label:
widgets.append(urwid.Text(label))
widgets += (urwid.AttrWrap(ButtonText(cb, data, txt), 'menu', 'selected') for (txt, cb, data) in items)
widgets.append(urwid.Divider())
widgets.pop()
return ShortcutListBox(widgets)
def push_wizard(name, pages, dimensions=(50, 10)):
state = {}
wiz = Wizard()
for page in pages:
if type(page) != tuple:
page = (page, )
wiz.add_panel( page[0](state, *page[1:]) )
push_window( urwid.Filler( urwid.Padding(
urwid.LineBox(wiz), 'center', dimensions[0]),
'middle', dimensions[1] ), name )
class ButtonText(urwid.Text):
def __init__(self, callback, data, *args, **kwargs):
self.callback = callback
self.data = data
urwid.Text.__init__(self, *args, **kwargs)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.callback:
self.callback(self.data)
else:
return key
#DONTUSE
class CaptionedText(urwid.Text):
def __init__(self, caption, *args, **kwargs):
self.caption = caption
urwid.Text.__init__(self, *args, **kwargs)
def render(self, *args, **kwargs):
self.set_text(self.caption + self.get_text()[0])
urwid.Text.render(*args, **kwargs)
class SingleEdit(urwid.Edit):
def keypress(self, size, key):
key_mappings = {
'enter': 'down',
'tab': 'down',
'shift tab': 'up',
'ctrl a': 'home',
'ctrl e': 'end'
}
if key in key_mappings:
return urwid.Edit.keypress(self, size, key_mappings[key])
else:
return urwid.Edit.keypress(self, size, key)
class SingleIntEdit(urwid.IntEdit):
def keypress(self, size, key):
if key == 'enter':
return urwid.Edit.keypress(self, size, 'down')
else:
return urwid.Edit.keypress(self, size, key)
class WordEdit(SingleEdit):
def valid_char(self, ch):
return urwid.Edit.valid_char(self, ch) and ch != ' '
class LdapWordEdit(WordEdit):
ldap = None
index = None
def __init__(self, uri, base, attr, *args):
try:
self.ldap = ldap.initialize(uri)
self.ldap.simple_bind_s("", "")
except ldap.LDAPError:
return WordEdit.__init__(self, *args)
self.base = base
self.attr = ldapi.escape(attr)
return WordEdit.__init__(self, *args)
def keypress(self, size, key):
if (key == 'tab' or key == 'shift tab') and self.ldap != None:
if self.index != None:
if key == 'tab':
self.index = (self.index + 1) % len(self.choices)
elif key == 'shift tab':
self.index = (self.index - 1) % len(self.choices)
text = self.choices[self.index]
self.set_edit_text(text)
self.set_edit_pos(len(text))
else:
try:
text = self.get_edit_text()
search = ldapi.escape(text)
matches = self.ldap.search_s(self.base,
ldap.SCOPE_SUBTREE, '(%s=%s*)' % (self.attr, search))
self.choices = [ text ]
for match in matches:
(_, attrs) = match
self.choices += attrs['uid']
self.choices.sort()
self.index = 0
self.keypress(size, key)
except ldap.LDAPError, e:
pass
else:
self.index = None
return WordEdit.keypress(self, size, key)
class LdapFilterWordEdit(LdapWordEdit):
def __init__(self, uri, base, attr, map, *args):
LdapWordEdit.__init__(self, uri, base, attr, *args)
self.map = map
def keypress(self, size, key):
if self.ldap != None:
if key == 'enter' or key == 'down' or key == 'up':
search = ldapi.escape(self.get_edit_text())
try:
matches = self.ldap.search_s(self.base,
ldap.SCOPE_SUBTREE, '(%s=%s)' % (self.attr, search))
if len(matches) > 0:
(_, attrs) = matches[0]
for (k, v) in self.map.items():
if attrs.has_key(k) and len(attrs[k]) > 0:
v.set_edit_text(attrs[k][0])
except ldap.LDAPError:
pass
return LdapWordEdit.keypress(self, size, key)
class PassEdit(SingleEdit):
def get_text(self):
text = urwid.Edit.get_text(self)
return (self.caption + " " * len(self.get_edit_text()), text[1])
class EnhancedButton(urwid.Button):
def keypress(self, size, key):
if key == 'tab':
return urwid.Button.keypress(self, size, 'down')
elif key == 'shift tab':
return urwid.Button.keypress(self, size, 'up')
else:
return urwid.Button.keypress(self, size, key)
class DumbColumns(urwid.Columns):
"""Dumb columns widget
The normal one tries to focus the "nearest" widget to the cursor.
This makes the Back button default instead of the Next button.
"""
def move_cursor_to_coords(self, size, col, row):
pass
class Wizard(urwid.WidgetWrap):
def __init__(self):
self.selected = None
self.panels = []
self.panelwrap = urwid.WidgetWrap( urwid.SolidFill() )
self.back = EnhancedButton("Back", self.back)
self.next = EnhancedButton("Next", self.next)
self.buttons = DumbColumns( [ self.back, self.next ], dividechars=3, focus_column=1 )
pad = urwid.Padding( self.buttons, ('fixed right', 2), 19 )
self.pile = urwid.Pile( [self.panelwrap, ('flow', pad)], 0 )
urwid.WidgetWrap.__init__(self, self.pile)
def add_panel(self, panel):
self.panels.append( panel )
if len(self.panels) == 1:
self.select(0)
def select(self, panelno, set_focus=True):
if 0 <= panelno < len(self.panels):
self.selected = panelno
self.panelwrap._w = self.panels[panelno]
self.panelwrap._invalidate()
self.panels[panelno].activate()
if set_focus:
if self.panels[panelno].focusable():
self.pile.set_focus( 0 )
else:
self.pile.set_focus( 1 )
def next(self, *args, **kwargs):
if self.panels[self.selected].check():
self.select( self.selected )
return
self.select(self.selected + 1)
def back(self, *args, **kwargs):
if self.selected == 0:
raise_back()
self.select(self.selected - 1, False)
class WizardPanel(urwid.WidgetWrap):
def __init__(self, state):
self.state = state
self.init_widgets()
self.box = urwid.ListBox( urwid.SimpleListWalker( self.widgets ) )
urwid.WidgetWrap.__init__( self, self.box )
def init_widgets(self):
self.widgets = []
def focus_widget(self, widget):
self.box.set_focus( self.widgets.index( widget ) )
def focusable(self):
return True
def check(self):
return
def activate(self):
return
# assumes that a SimpleListWalker containing
# urwid.Text or subclass is used
class ShortcutListBox(urwid.ListBox):
def keypress(self, size, key):
# only process single letters; pass all else to super
if len(key) == 1 and key.isalpha():
next = self.get_focus()[1] + 1
shifted_contents = self.body.contents[next:] + self.body.contents[:next]
# find the next item matching the letter requested
try:
new_focus = (i for i,w in enumerate(shifted_contents)
if w.selectable() and w.text[0].upper() == key.upper()).next()
new_focus = (new_focus + next) % len(self.body.contents)
self.set_focus(new_focus)
except:
# ring the bell if it isn't found
sys.stdout.write('\a')
else:
urwid.ListBox.keypress(self, size, key)

View File

@ -1,80 +0,0 @@
import urwid
window_stack = []
window_names = []
header = urwid.Text( "" )
footer = urwid.Text( "" )
ui = urwid.curses_display.Screen()
ui.register_palette([
# name, foreground, background, mono
('banner', 'light gray', 'default', None),
('menu', 'light gray', 'default', 'bold'),
('selected', 'black', 'light gray', 'bold'),
])
top = urwid.Frame( urwid.SolidFill(), header, footer )
def push_window( frame, name=None ):
window_stack.append( frame )
window_names.append( name )
update_top()
def pop_window():
if len(window_stack) == 1:
return False
window_stack.pop()
window_names.pop()
update_top()
clear_status()
return True
def update_top():
names = [ n for n in window_names if n ]
header.set_text(" - ".join( names ) + "\n")
top.set_body( window_stack[-1] )
def set_status(message):
footer.set_text(message)
def clear_status():
footer.set_text("")
class Abort(Exception):
pass
class Back(Exception):
pass
def raise_abort(*args, **kwargs):
raise Abort()
def raise_back(*args, **kwarg):
raise Back()
def redraw():
cols, rows = ui.get_cols_rows()
canvas = top.render( (cols, rows), focus=True )
ui.draw_screen( (cols, rows), canvas )
return cols, rows
def event_loop(ui):
while True:
try:
cols, rows = redraw()
keys = ui.get_input()
for k in keys:
if k == "esc":
if not pop_window():
break
elif k == "window resize":
(cols, rows) = ui.get_cols_rows()
else:
top.keypress( (cols, rows), k )
except Back:
pop_window()
except (Abort, KeyboardInterrupt):
return

58
ceo/utils.py Normal file
View File

@ -0,0 +1,58 @@
from typing import List, Dict
import requests
from zope import component
from ceo_common.interfaces import IHTTPClient, IConfig
def http_request(method: str, path: str, **kwargs) -> requests.Response:
client = component.getUtility(IHTTPClient)
cfg = component.getUtility(IConfig)
if path.startswith('/api/db'):
host = cfg.get('ceod_database_host')
delegate = False
else:
host = cfg.get('ceod_admin_host')
# The forwarded TGT is only needed for endpoints which write to LDAP
delegate = method != 'GET'
return client.request(
host, path, method, delegate=delegate, stream=True, **kwargs)
def http_get(path: str, **kwargs) -> requests.Response:
return http_request('GET', path, **kwargs)
def http_post(path: str, **kwargs) -> requests.Response:
return http_request('POST', path, **kwargs)
def http_patch(path: str, **kwargs) -> requests.Response:
return http_request('PATCH', path, **kwargs)
def http_delete(path: str, **kwargs) -> requests.Response:
return http_request('DELETE', path, **kwargs)
def get_failed_operations(data: List[Dict]) -> List[str]:
"""
Get a list of the failed operations using the JSON objects
streamed from the server.
"""
prefix = 'failed_to_'
failed = []
for d in data:
if 'operation' not in d:
continue
operation = d['operation']
if not operation.startswith(prefix):
continue
operation = operation[len(prefix):]
if ':' in operation:
# sometimes the operation looks like
# "failed_to_do_something: error message"
operation = operation[:operation.index(':')]
failed.append(operation)
return failed

View File

@ -1,8 +0,0 @@
def uri():
return "ldap://uwldap.uwaterloo.ca/"
def base():
return "dc=uwaterloo,dc=ca"
def domain():
return 'uwaterloo.ca'

0
ceo_common/__init__.py Normal file
View File

66
ceo_common/errors.py Normal file
View File

@ -0,0 +1,66 @@
class UserNotFoundError(Exception):
def __init__(self, username):
super().__init__(f"user '{username}' not found")
class GroupNotFoundError(Exception):
def __init__(self, group_name):
super().__init__(f"group '{group_name}' not found")
class BadRequest(Exception):
pass
class KerberosError(Exception):
pass
class UserAlreadyExistsError(Exception):
def __init__(self):
super().__init__('user already exists')
class GroupAlreadyExistsError(Exception):
def __init__(self):
super().__init__('group already exists')
class UserAlreadyInGroupError(Exception):
def __init__(self):
super().__init__('user is already in group')
class UserNotInGroupError(Exception):
def __init__(self):
super().__init__('user is not in group')
class UserAlreadySubscribedError(Exception):
def __init__(self):
super().__init__('user is already subscribed')
class UserNotSubscribedError(Exception):
def __init__(self):
super().__init__('user is not subscribed')
class NoSuchListError(Exception):
def __init__(self):
super().__init__('mailing list does not exist')
class InvalidUsernameError(Exception):
def __init__(self):
super().__init__('Username contains characters that are not allowed')
class DatabaseConnectionError(Exception):
def __init__(self):
super().__init__('unable to connect or authenticate to sql service')
class DatabasePermissionError(Exception):
def __init__(self):
super().__init__('unable to perform action due to lack of permissions')

View File

@ -0,0 +1,8 @@
from zope.interface import Interface
class IConfig(Interface):
"""Represents a config store."""
def get(key: str) -> str:
"""Get the config value for the given key."""

View File

@ -0,0 +1,18 @@
from zope.interface import Attribute, Interface
class IDatabaseService(Interface):
"""Interface to create databases for users."""
type = Attribute('the type of databases that will be created')
auth_username = Attribute('username to a privileged user on the database host')
auth_password = Attribute('password to a privileged user on the database host')
def create_db(username: str) -> str:
"""create a user and database and return the password"""
def reset_passwd(username: str) -> str:
"""reset user password and return it"""
def delete_db(username: str):
"""remove user and delete their database"""

View File

@ -0,0 +1,29 @@
from typing import List
from zope.interface import Interface
from .IUser import IUser
class IFileService(Interface):
"""
A service which can access, create and modify files on the
NFS users' directory.
"""
def create_home_dir(user: IUser):
"""
Create a new home dir for the given user or club.
"""
def delete_home_dir(user: IUser):
"""Permanently delete a user's home dir."""
def get_forwarding_addresses(user: IUser) -> List[str]:
"""
Get the contents of the user's ~/.forward file,
one line at a time.
"""
def set_forwarding_addresses(user: IUser, addresses: List[str]):
"""Set the contents of the user's ~/.forward file."""

View File

@ -0,0 +1,30 @@
from typing import List
from zope.interface import Interface, Attribute
class IGroup(Interface):
"""Represents a Unix group."""
cn = Attribute('common name')
gid_number = Attribute('gid number')
description = Attribute('optional description')
members = Attribute('usernames of group members')
ldap3_entry = Attribute('cached ldap3.Entry instance for this group')
user_cn = Attribute('cached CN of the user associated with this group')
def add_to_ldap():
"""Add a new record to LDAP for this group."""
def add_member(username: str):
"""Add the member to this group in LDAP."""
def remove_member(username: str):
"""Remove the member from this group in LDAP."""
def set_members(usernames: List[str]):
"""Set all of the members of this group in LDAP."""
def to_dict():
"""Serialize this group as JSON."""

View File

@ -0,0 +1,24 @@
from zope.interface import Interface
class IHTTPClient(Interface):
"""A helper class for HTTP requests to ceod."""
def request(host: str, api_path: str, method: str, delegate: bool, **kwargs):
"""
Make an HTTP request.
If `delegate` is True, GSSAPI credentials will be forwarded to the
remote.
"""
def get(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a GET request."""
def post(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a POST request."""
def patch(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a PATCH request."""
def delete(host: str, api_path: str, delegate: bool = True, **kwargs):
"""Make a DELETE request."""

View File

@ -0,0 +1,14 @@
from zope.interface import Interface
class IKerberosService(Interface):
"""A utility wrapper around kinit/kadmin."""
def addprinc(principal: str, password: str):
"""Add a new principal with the specified password."""
def delprinc(principal: str):
"""Remove a principal."""
def change_password(principal: str, password: str):
"""Set and expire the principal's password."""

View File

@ -0,0 +1,89 @@
from typing import List, Dict, Union
from zope.interface import Interface
from .IUser import IUser
from .IGroup import IGroup
class ILDAPService(Interface):
"""An interface to the LDAP database."""
def uid_to_dn(self, uid: str) -> str:
"""Get the LDAP DN for the user with this UID."""
def group_cn_to_dn(self, cn: str) -> str:
"""Get the LDAP DN for the group with this CN."""
def get_user(username: str) -> IUser:
"""Retrieve the user with the given username."""
def get_display_info_for_users(usernames: List[str]) -> List[Dict[str, str]]:
"""
Retrieve a subset of the LDAP attributes for the given users.
Useful for displaying a list of users in a compact way.
"""
def get_users_with_positions(self) -> List[IUser]:
"""Retrieve users who have a non-empty position attribute."""
def add_user(user: IUser):
"""
Add the user to the database.
A new UID and GID will be generated and returned in the new user.
"""
def remove_user(user: IUser):
"""Remove this user from the database."""
def get_group(cn: str, is_club: bool = False) -> IGroup:
"""Retrieve the group with the given cn (Unix group name)."""
def add_group(group: IGroup):
"""
Add the group to the database.
The GID will not be changed and must be valid.
"""
def remove_group(group: IGroup):
"""Remove this group from the database."""
def entry_ctx_for_user(user: IUser):
"""
Get a context manager which yields an ldap3.WritableEntry
for this user.
"""
def entry_ctx_for_group(group: IGroup):
"""
Get a context manager which yields an ldap3.WritableEntry
for this group.
"""
def add_sudo_role(uid: str):
"""Create a sudo role for the club with this UID."""
def remove_sudo_role(uid: str):
"""Remove the sudo role for this club from the database."""
def update_programs(
dry_run: bool = False,
members: Union[List[str], None] = None,
):
"""
Sync the 'program' attribute in CSC LDAP with UW LDAP.
If `dry_run` is set to True, then a list of members whose programs
*would* be changed is returned along with their old and new programs:
```
[
('user1', 'old_program1', 'new_program1'),
('user2', 'old_program2', 'new_program2'),
...
]
```
If `members` is set to a list of usernames, then only
those members will (possibly) have their programs updated.
On success, a list of members whose programs *were* changed will
be returned along with their new programs, in the same format
described above.
"""

View File

@ -0,0 +1,25 @@
from typing import Dict, List
from zope.interface import Interface
from .IUser import IUser
class IMailService(Interface):
"""An interface to send email messages."""
def send(_from: str, to: str, headers: Dict[str, str], content: str):
"""Send a message with the given headers and content."""
def send_welcome_message_to(user: IUser, password: str):
"""
Send a welcome message to the new member, including their temporary
password.
"""
def announce_new_user(user: IUser, operations: List[str]):
"""
Announce to the ceo mailing list that the new user was created.
`operations` is a list of the operations which were performed
during the transaction.
"""

View File

@ -0,0 +1,11 @@
from zope.interface import Interface
class IMailmanService(Interface):
"""A service to susbcribe and unsubscribe people from mailing lists."""
def subscribe(address: str, mailing_list: str):
"""Subscribe the email address to the mailing list."""
def unsubscribe(address: str, mailing_list: str):
"""Unsubscribe the email address from the mailing list."""

View File

@ -0,0 +1,25 @@
from typing import List, Union
from zope.interface import Interface
class IUWLDAPService(Interface):
"""Represents the UW LDAP database."""
def get_user(username: str):
"""
Return the LDAP record for the given user, or
None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
"""
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
"""
Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in
the returned list will be None.
Example:
get_programs_for_users(['user1', 'user2', 'user3'])
-> ['program1', None, 'program2']
"""

View File

@ -0,0 +1,82 @@
from typing import List, Dict
from zope.interface import Interface, Attribute
class IUser(Interface):
"""Represents a Unix user."""
# LDAP attributes
uid = Attribute('user identifier')
cn = Attribute('common name')
login_shell = Attribute('login shell')
uid_number = Attribute('uid number')
gid_number = Attribute('gid number')
home_directory = Attribute('home directory')
program = Attribute('academic program')
position = Attribute('executive position')
terms = Attribute('list of terms for which this person was a member')
non_member_terms = Attribute('list of terms for which this person was '
'a club rep')
mail_local_addresses = Attribute('email aliases')
# Non-LDAP attributes
ldap3_entry = Attribute('cached ldap3.Entry instance for this user')
def get_forwarding_addresses(self) -> List[str]:
"""Get the forwarding addresses for this user."""
def set_forwarding_addresses(self, addresses: List[str]):
"""Set the forwarding addresses for this user."""
def is_club() -> bool:
"""
Returns True if this is the Unix user for a club.
Returns False if this is the Unix user for a member.
"""
def add_to_ldap():
"""
Add a new record to LDAP for this user.
A new UID number and GID number will be created.
"""
def add_to_kerberos(password: str):
"""Add a new Kerberos principal for this user."""
def remove_from_kerberos():
"""Remove this user from Kerberos."""
def add_terms(terms: List[str]):
"""Add member terms for this user."""
def add_non_member_terms(terms: List[str]):
"""Add non-member terms for this user."""
def set_positions(self, positions: List[str]):
"""Set the positions for this user."""
def change_password(password: str):
"""Replace this user's password."""
def replace_login_shell(login_shell: str):
"""Replace this user's login shell."""
def create_home_dir():
"""Create a new home directory for this user."""
def delete_home_dir():
"""Delete this user's home dir."""
def subscribe_to_mailing_list(mailing_list: str):
"""Subscribe this user to the mailing list."""
def unsubscribe_from_mailing_list(mailing_list: str):
"""Unsubscribe this user from the mailing list."""
def to_dict(get_forwarding_addresses: bool = False) -> Dict:
"""
Serialize this user into a dict.
If get_forwarding_addresses is True, the forwarding addresses
for the user will also be returned, if present.
"""

View File

@ -0,0 +1,11 @@
from .IKerberosService import IKerberosService
from .IConfig import IConfig
from .IUser import IUser
from .ILDAPService import ILDAPService
from .IGroup import IGroup
from .IFileService import IFileService
from .IUWLDAPService import IUWLDAPService
from .IMailService import IMailService
from .IMailmanService import IMailmanService
from .IHTTPClient import IHTTPClient
from .IDatabaseService import IDatabaseService

View File

@ -0,0 +1,16 @@
import logging
__ALL__ = ['logger_factory']
def logger_factory(name: str) -> logging.Logger:
logger = logging.getLogger(name)
if logger.hasHandlers():
# already initialized
return logger
logger.setLevel(logging.DEBUG)
log_handler = logging.StreamHandler()
log_handler.setLevel(logging.DEBUG)
log_handler.setFormatter(logging.Formatter('%(levelname)s %(name)s: %(message)s'))
logger.addHandler(log_handler)
return logger

View File

@ -0,0 +1,32 @@
from configparser import ConfigParser
import os
from typing import Union
from zope.interface import implementer
from ceo_common.interfaces import IConfig
@implementer(IConfig)
class Config:
def __init__(self, config_file: Union[str, None] = None):
if config_file is None:
config_file = os.environ.get('CEOD_CONFIG', '/etc/csc/ceod.ini')
self.config = ConfigParser()
self.config.read(config_file)
def get(self, key: str) -> str:
section, subkey = key.split('_', 1)
if section in self.config:
val = self.config[section][subkey]
else:
val = self.config['DEFAULT'][key]
if val.isdigit():
return int(val)
if val.lower() in ['true', 'yes']:
return True
if val.lower() in ['false', 'no']:
return False
if section.startswith('auxiliary ') or section == 'positions':
return [item.strip() for item in val.split(',')]
return val

View File

@ -0,0 +1,59 @@
import flask
import gssapi
import requests
from requests_gssapi import HTTPSPNEGOAuth
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IConfig, IHTTPClient
@implementer(IHTTPClient)
class HTTPClient:
def __init__(self):
# Determine how to connect to other ceod instances
cfg = component.getUtility(IConfig)
if cfg.get('ceod_use_https'):
self.scheme = 'https'
else:
self.scheme = 'http'
self.ceod_port = cfg.get('ceod_port')
self.base_domain = cfg.get('base_domain')
def request(self, host: str, api_path: str, method: str, delegate: bool, **kwargs):
# always use the FQDN
if '.' not in host:
host = host + '.' + self.base_domain
# SPNEGO
spnego_kwargs = {
'opportunistic_auth': True,
'target_name': gssapi.Name('ceod/' + host),
}
if flask.has_request_context() and 'client_token' in flask.g:
# This is reached when we are the server and the client has forwarded
# their credentials to us.
spnego_kwargs['creds'] = gssapi.Credentials(token=flask.g.client_token)
if delegate:
# This is reached when we are the client and we want to forward our
# credentials to the server.
spnego_kwargs['delegate'] = True
auth = HTTPSPNEGOAuth(**spnego_kwargs)
return requests.request(
method,
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
auth=auth, **kwargs,
)
def get(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'GET', delegate, **kwargs)
def post(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'POST', delegate, **kwargs)
def patch(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'PATCH', delegate, **kwargs)
def delete(self, host: str, api_path: str, delegate: bool = True, **kwargs):
return self.request(host, api_path, 'DELETE', delegate, **kwargs)

View File

@ -0,0 +1,34 @@
from zope import component
from zope.interface import implementer
from ..errors import UserAlreadySubscribedError, NoSuchListError, \
UserNotSubscribedError
from ..interfaces import IMailmanService, IConfig, IHTTPClient
@implementer(IMailmanService)
class RemoteMailmanService:
def __init__(self):
cfg = component.getUtility(IConfig)
self.mailman_host = cfg.get('ceod_mailman_host')
self.http_client = component.getUtility(IHTTPClient)
def subscribe(self, address: str, mailing_list: str):
resp = self.http_client.post(
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
delegate=False)
if not resp.ok:
if resp.status_code == 409:
raise UserAlreadySubscribedError()
elif resp.status_code == 404:
raise NoSuchListError()
raise Exception(resp.json())
def unsubscribe(self, address: str, mailing_list: str):
resp = self.http_client.delete(
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
delegate=False)
if not resp.ok:
if resp.status_code == 404:
raise UserNotSubscribedError()
raise Exception(resp.json())

67
ceo_common/model/Term.py Normal file
View File

@ -0,0 +1,67 @@
import datetime
class Term:
"""A representation of a term in the CSC LDAP, e.g. 's2021'."""
seasons = ['w', 's', 'f']
def __init__(self, s_term: str):
assert len(s_term) == 5 and s_term[0] in self.seasons and \
s_term[1:].isdigit()
self.s_term = s_term
def __repr__(self):
return self.s_term
@staticmethod
def current():
"""Get a Term object for the current date."""
dt = datetime.datetime.now()
c = 'w'
if 5 <= dt.month <= 8:
c = 's'
elif 9 <= dt.month:
c = 'f'
s_term = c + str(dt.year)
return Term(s_term)
def __add__(self, other):
assert type(other) is int and other >= 0
c = self.s_term[0]
season_idx = self.seasons.index(c)
year = int(self.s_term[1:])
year += other // 3
season_idx += other % 3
if season_idx >= 3:
year += 1
season_idx -= 3
s_term = self.seasons[season_idx] + str(year)
return Term(s_term)
def __eq__(self, other):
return isinstance(other, Term) and self.s_term == other.s_term
def __lt__(self, other):
if not isinstance(other, Term):
return NotImplemented
c1, c2 = self.s_term[0], other.s_term[0]
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:])
return year1 < year2 or (
year1 == year2 and self.seasons.index(c1) < self.seasons.index(c2)
)
def __gt__(self, other):
if not isinstance(other, Term):
return NotImplemented
c1, c2 = self.s_term[0], other.s_term[0]
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:])
return year1 > year2 or (
year1 == year2 and self.seasons.index(c1) > self.seasons.index(c2)
)
def __ge__(self, other):
return self > other or self == other
def __le__(self, other):
return self < other or self == other

View File

@ -0,0 +1,4 @@
from .Config import Config
from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService
from .Term import Term

0
ceod/__init__.py Normal file
View File

1
ceod/api/__init__.py Normal file
View File

@ -0,0 +1 @@
from .app_factory import create_app

120
ceod/api/app_factory.py Normal file
View File

@ -0,0 +1,120 @@
import importlib.resources
import os
import socket
from flask import Flask
from zope import component
from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService
from ceod.db import MySQLService, PostgreSQLService
def create_app(flask_config={}):
app = Flask(__name__)
app.config.from_mapping(flask_config)
if not app.config.get('TESTING'):
register_services(app)
cfg = component.getUtility(IConfig)
init_spnego('ceod')
hostname = socket.gethostname()
# Only ceod_admin_host should serve the /api/members endpoints because
# it needs to run kadmin
if hostname == cfg.get('ceod_admin_host'):
from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members')
# Only offer mailman API if this host is running Mailman
if hostname == cfg.get('ceod_mailman_host'):
from ceod.api import mailman
app.register_blueprint(mailman.bp, url_prefix='/api/mailman')
if hostname == cfg.get('ceod_database_host'):
from ceod.api import database
app.register_blueprint(database.bp, url_prefix='/api/db')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
register_error_handlers(app)
@app.route('/ping')
def ping():
"""Health check"""
return 'pong\n'
return app
def register_services(app):
# Config
if app.config.get('ENV') == 'development' and 'CEOD_CONFIG' not in os.environ:
with importlib.resources.path('tests', 'ceod_dev.ini') as p:
config_file = p.__fspath__()
else:
config_file = None
cfg = Config(config_file)
component.provideUtility(cfg, IConfig)
# KerberosService
hostname = socket.gethostname()
fqdn = socket.getfqdn()
# Only ceod_admin_host has the ceod/admin key in its keytab
if hostname == cfg.get('ceod_admin_host'):
principal = cfg.get('ldap_admin_principal')
else:
principal = f'ceod/{fqdn}'
krb_srv = KerberosService(principal)
component.provideUtility(krb_srv, IKerberosService)
# LDAPService
ldap_srv = LDAPService()
component.provideUtility(ldap_srv, ILDAPService)
# HTTPService
http_client = HTTPClient()
component.provideUtility(http_client, IHTTPClient)
# FileService
if hostname == cfg.get('ceod_fs_root_host'):
file_srv = FileService()
component.provideUtility(file_srv, IFileService)
# MailmanService
if hostname == cfg.get('ceod_mailman_host'):
mailman_srv = MailmanService()
else:
mailman_srv = RemoteMailmanService()
component.provideUtility(mailman_srv, IMailmanService)
# MailService
mail_srv = MailService()
component.provideUtility(mail_srv, IMailService)
# UWLDAPService
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
# MySQLService
if hostname == cfg.get('ceod_database_host'):
mysql_srv = MySQLService()
component.provideUtility(mysql_srv, IDatabaseService, 'mysql')
# PostgreSQLService
if hostname == cfg.get('ceod_database_host'):
psql_srv = PostgreSQLService()
component.provideUtility(psql_srv, IDatabaseService, 'postgresql')

104
ceod/api/database.py Normal file
View File

@ -0,0 +1,104 @@
from flask import Blueprint
from zope import component
from functools import wraps
from ceod.api.utils import authz_restrict_to_syscom, user_is_in_group, \
requires_authentication_no_realm, development_only
from ceo_common.errors import UserNotFoundError, DatabaseConnectionError, DatabasePermissionError, \
InvalidUsernameError, UserAlreadyExistsError
from ceo_common.interfaces import ILDAPService, IDatabaseService
bp = Blueprint('db', __name__)
def db_exception_handler(func):
@wraps(func)
def function(db_type: str, username: str):
try:
# Username should not contain symbols.
# Underscores are allowed.
for c in username:
if not (c.isalnum() or c == '_'):
raise InvalidUsernameError()
ldap_srv = component.getUtility(ILDAPService)
ldap_srv.get_user(username) # make sure user exists
return func(db_type, username)
except UserNotFoundError:
return {'error': 'user not found'}, 404
except UserAlreadyExistsError:
return {'error': 'database user is already created'}, 409
except InvalidUsernameError:
return {'error': 'username contains invalid characters'}, 400
except DatabaseConnectionError:
return {'error': 'unable to connect or authenticate to sql server'}, 500
except DatabasePermissionError:
return {'error': 'unable to perform action due to permissions'}, 500
return function
@db_exception_handler
def create_db_from_type(db_type: str, username: str):
db_srv = component.getUtility(IDatabaseService, db_type)
password = db_srv.create_db(username)
return {'password': password}
@db_exception_handler
def reset_db_passwd_from_type(db_type: str, username: str):
db_srv = component.getUtility(IDatabaseService, db_type)
password = db_srv.reset_db_passwd(username)
return {'password': password}
@db_exception_handler
def delete_db_from_type(db_type: str, username: str):
db_srv = component.getUtility(IDatabaseService, db_type)
db_srv.delete_db(username)
return {'status': 'OK'}
@bp.route('/mysql/<username>', methods=['POST'])
@requires_authentication_no_realm
def create_mysql_db(auth_user: str, username: str):
if not (auth_user == username or user_is_in_group(auth_user, 'syscom')):
return {'error': "not authorized to create databases for others"}, 403
return create_db_from_type('mysql', username)
@bp.route('/postgresql/<username>', methods=['POST'])
@requires_authentication_no_realm
def create_postgresql_db(auth_user: str, username: str):
if not (auth_user == username or user_is_in_group(auth_user, 'syscom')):
return {'error': "not authorized to create databases for others"}, 403
return create_db_from_type('postgresql', username)
@bp.route('/mysql/<username>/pwreset', methods=['POST'])
@requires_authentication_no_realm
def reset_mysql_db_passwd(auth_user: str, username: str):
if not (auth_user == username or user_is_in_group(auth_user, 'syscom')):
return {'error': "not authorized to request password reset for others"}, 403
return reset_db_passwd_from_type('mysql', username)
@bp.route('/postgresql/<username>/pwreset', methods=['POST'])
@requires_authentication_no_realm
def reset_postgresql_db_passwd(auth_user: str, username: str):
if not (auth_user == username or user_is_in_group(auth_user, 'syscom')):
return {'error': "not authorized to request password reset for others"}, 403
return reset_db_passwd_from_type('postgresql', username)
@bp.route('/mysql/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
@development_only
def delete_mysql_db(username: str):
return delete_db_from_type('mysql', username)
@bp.route('/postgresql/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
@development_only
def delete_postgresql_db(username: str):
return delete_db_from_type('postgresql', username)

View File

@ -0,0 +1,30 @@
import traceback
from flask.app import Flask
import ldap3
from werkzeug.exceptions import HTTPException
from ceo_common.errors import UserNotFoundError, GroupNotFoundError
from ceo_common.logger_factory import logger_factory
__all__ = ['register_error_handlers']
logger = logger_factory(__name__)
def register_error_handlers(app: Flask):
"""Register error handlers for the application."""
app.register_error_handler(Exception, generic_error_handler)
def generic_error_handler(err: Exception):
"""Return JSON for all errors."""
if isinstance(err, HTTPException):
status_code = err.code
elif isinstance(err, UserNotFoundError) or isinstance(err, GroupNotFoundError):
status_code = 404
elif isinstance(err, ldap3.core.exceptions.LDAPStrongerAuthRequiredResult):
status_code = 403
else:
status_code = 500
logger.error(traceback.format_exc())
return {'error': type(err).__name__ + ': ' + str(err)}, status_code

68
ceod/api/groups.py Normal file
View File

@ -0,0 +1,68 @@
from flask import Blueprint, request
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy, \
create_streaming_response, development_only
from ceo_common.interfaces import ILDAPService
from ceod.transactions.groups import (
AddGroupTransaction,
AddMemberToGroupTransaction,
RemoveMemberFromGroupTransaction,
DeleteGroupTransaction,
)
bp = Blueprint('groups', __name__)
@bp.route('/', methods=['POST'], strict_slashes=False)
@authz_restrict_to_syscom
def create_group():
body = request.get_json(force=True)
txn = AddGroupTransaction(
cn=body['cn'],
description=body['description'],
)
return create_streaming_response(txn)
@bp.route('/<group_name>')
def get_group(group_name):
ldap_srv = component.getUtility(ILDAPService)
group = ldap_srv.get_group(group_name)
return group.to_dict()
@bp.route('/<group_name>/members/<username>', methods=['POST'])
@authz_restrict_to_syscom
def add_member_to_group(group_name, username):
subscribe_to_lists = is_truthy(
request.args.get('subscribe_to_lists', 'true')
)
txn = AddMemberToGroupTransaction(
username=username,
group_name=group_name,
subscribe_to_lists=subscribe_to_lists,
)
return create_streaming_response(txn)
@bp.route('/<group_name>/members/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
def remove_member_from_group(group_name, username):
unsubscribe_from_lists = is_truthy(
request.args.get('unsubscribe_from_lists', 'true')
)
txn = RemoveMemberFromGroupTransaction(
username=username,
group_name=group_name,
unsubscribe_from_lists=unsubscribe_from_lists,
)
return create_streaming_response(txn)
@bp.route('/<group_name>', methods=['DELETE'])
@authz_restrict_to_syscom
@development_only
def delete_group(group_name):
txn = DeleteGroupTransaction(group_name)
return create_streaming_response(txn)

33
ceod/api/mailman.py Normal file
View File

@ -0,0 +1,33 @@
from flask import Blueprint
from zope import component
from .utils import authz_restrict_to_staff
from ceo_common.errors import UserAlreadySubscribedError, UserNotSubscribedError, \
NoSuchListError
from ceo_common.interfaces import IMailmanService
bp = Blueprint('mailman', __name__)
@bp.route('/<mailing_list>/<username>', methods=['POST'])
@authz_restrict_to_staff
def subscribe(mailing_list, username):
mailman_srv = component.getUtility(IMailmanService)
try:
mailman_srv.subscribe(username, mailing_list)
except UserAlreadySubscribedError as err:
return {'error': str(err)}, 409
except NoSuchListError as err:
return {'error': str(err)}, 404
return {'result': 'OK'}
@bp.route('/<mailing_list>/<username>', methods=['DELETE'])
@authz_restrict_to_staff
def unsubscribe(mailing_list, username):
mailman_srv = component.getUtility(IMailmanService)
try:
mailman_srv.unsubscribe(username, mailing_list)
except (UserNotSubscribedError, NoSuchListError) as err:
return {'error': str(err)}, 404
return {'result': 'OK'}

97
ceod/api/members.py Normal file
View File

@ -0,0 +1,97 @@
from flask import Blueprint, request
from zope import component
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
user_is_in_group, requires_authentication_no_realm, \
create_streaming_response, development_only
from ceo_common.errors import BadRequest
from ceo_common.interfaces import ILDAPService
from ceod.transactions.members import (
AddMemberTransaction,
ModifyMemberTransaction,
DeleteMemberTransaction,
)
import ceod.utils as utils
bp = Blueprint('members', __name__)
@bp.route('/', methods=['POST'], strict_slashes=False)
@authz_restrict_to_staff
def create_user():
body = request.get_json(force=True)
txn = AddMemberTransaction(
uid=body['uid'],
cn=body['cn'],
program=body.get('program'),
terms=body.get('terms'),
non_member_terms=body.get('non_member_terms'),
forwarding_addresses=body.get('forwarding_addresses'),
)
return create_streaming_response(txn)
@bp.route('/<username>')
@requires_authentication_no_realm
def get_user(auth_user: str, username: str):
get_forwarding_addresses = False
if user_is_in_group(auth_user, 'syscom'):
# Only syscom members may see the user's forwarding addresses,
# since this requires reading a file in the user's home directory.
# To avoid situations where an unprivileged user symlinks their
# ~/.forward file to /etc/shadow or something, we don't allow
# non-syscom members to use this option either.
get_forwarding_addresses = True
ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username)
return user.to_dict(get_forwarding_addresses)
@bp.route('/<username>', methods=['PATCH'])
@requires_authentication_no_realm
def patch_user(auth_user: str, username: str):
if not (auth_user == username or user_is_in_group(auth_user, 'syscom')):
return {
'error': "You are not authorized to modify other users' attributes"
}, 403
body = request.get_json(force=True)
txn = ModifyMemberTransaction(
username,
login_shell=body.get('login_shell'),
forwarding_addresses=body.get('forwarding_addresses'),
)
return create_streaming_response(txn)
@bp.route('/<username>/renew', methods=['POST'])
@authz_restrict_to_staff
def renew_user(username: str):
body = request.get_json(force=True)
ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username)
if body.get('terms'):
user.add_terms(body['terms'])
return {'terms_added': body['terms']}
elif body.get('non_member_terms'):
user.add_non_member_terms(body['non_member_terms'])
return {'non_member_terms_added': body['non_member_terms']}
else:
raise BadRequest('Must specify either terms or non-member terms')
@bp.route('/<username>/pwreset', methods=['POST'])
@authz_restrict_to_syscom
def reset_user_password(username: str):
user = component.getUtility(ILDAPService).get_user(username)
password = utils.gen_password()
user.change_password(password)
return {'password': password}
@bp.route('/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
@development_only
def delete_user(username: str):
txn = DeleteMemberTransaction(username)
return create_streaming_response(txn)

45
ceod/api/positions.py Normal file
View File

@ -0,0 +1,45 @@
from flask import Blueprint, request
from zope import component
from .utils import authz_restrict_to_syscom, create_streaming_response
from ceo_common.interfaces import ILDAPService, IConfig
from ceod.transactions.members import UpdateMemberPositionsTransaction
bp = Blueprint('positions', __name__)
@bp.route('/', methods=['GET'], strict_slashes=False)
def get_positions():
ldap_srv = component.getUtility(ILDAPService)
positions = {}
for user in ldap_srv.get_users_with_positions():
for position in user.positions:
positions[position] = user.uid
return positions
@bp.route('/', methods=['POST'], strict_slashes=False)
@authz_restrict_to_syscom
def update_positions():
cfg = component.getUtility(IConfig)
body = request.get_json(force=True)
required = cfg.get('positions_required')
available = cfg.get('positions_available')
for position in body.keys():
if position not in available:
return {
'error': f'unknown position: {position}'
}, 400
for position in required:
if position not in body:
return {
'error': f'missing required position: {position}'
}, 400
txn = UpdateMemberPositionsTransaction(body)
return create_streaming_response(txn)

69
ceod/api/spnego.py Normal file
View File

@ -0,0 +1,69 @@
from base64 import b64decode, b64encode
import functools
import socket
from typing import Union
from flask import request, Response, make_response, g
import gssapi
from gssapi.raw import RequirementFlag
_server_name = None
def init_spnego(service_name: str, fqdn: Union[str, None] = None):
"""Set the server principal which will be used for SPNEGO."""
global _server_name
if fqdn is None:
fqdn = socket.getfqdn()
_server_name = gssapi.Name('ceod/' + fqdn)
# make sure that we're actually capable of acquiring credentials
gssapi.Credentials(usage='accept', name=_server_name)
def requires_authentication(f):
"""
Requires that all requests to f have a GSSAPI initiator token.
The initiator principal will be passed to the first argument of f
in the form user@REALM.
"""
@functools.wraps(f)
def wrapper(*args, **kwargs):
if 'authorization' not in request.headers:
return Response('Unauthorized', 401, {'WWW-Authenticate': 'Negotiate'})
header = request.headers['authorization']
client_token = b64decode(header.split()[1])
creds = gssapi.Credentials(usage='accept', name=_server_name)
ctx = gssapi.SecurityContext(creds=creds, usage='accept')
server_token = ctx.step(client_token)
# OK so we're going to cheat a bit here by assuming that Kerberos is the
# mechanism being used (which we know will be true). We know that Kerberos
# only requires one round-trip for the service handshake, so we don't need
# to store state between requests. Just to be sure, we assert that this is
# indeed the case.
# (This isn't compliant with the GSSAPI spec, but why write more code than
# necessary?)
assert ctx.complete, 'only one round trip expected'
# Store the username in flask.g
client_princ = str(ctx.initiator_name)
g.auth_user = client_princ[:client_princ.index('@')]
# Store the delegated credentials, if they were given
if ctx.actual_flags & RequirementFlag.delegate_to_peer:
# For some reason, shit gets screwed up when you try to use a
# gssapi.Credentials object which was created in another function.
# So we're going to export the token instead (which is a bytes
# object) and pass it to Credentials() whenever we need it.
g.client_token = ctx.delegated_creds.export()
# TODO: don't pass client_princ to f anymore since it's stored in flask.g
resp = make_response(f(client_princ, *args, **kwargs))
# RFC 2744, section 5.1:
# "If no token need be sent, gss_accept_sec_context will indicate this
# by setting the length field of the output_token argument to zero."
if server_token is not None:
resp.headers['WWW-Authenticate'] = 'Negotiate ' + b64encode(server_token).decode()
return resp
return wrapper

132
ceod/api/utils.py Normal file
View File

@ -0,0 +1,132 @@
import functools
import grp
import json
import os
import pwd
import traceback
from typing import Callable, List
from flask import current_app, stream_with_context
from .spnego import requires_authentication
from ceo_common.logger_factory import logger_factory
from ceod.transactions import AbstractTransaction
logger = logger_factory(__name__)
def requires_authentication_no_realm(f: Callable) -> Callable:
"""
Like requires_authentication, but strips the realm out of the principal string.
e.g. user1@CSCLUB.UWATERLOO.CA -> user1
"""
@requires_authentication
@functools.wraps(f)
def wrapper(principal: str, *args, **kwargs):
user = principal[:principal.index('@')]
logger.debug(f'received request from {user}')
return f(user, *args, **kwargs)
return wrapper
def user_is_in_group(user: str, group: str) -> bool:
"""Returns True if `user` is in `group`, False otherwise."""
return user in grp.getgrnam(group).gr_mem
def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable:
"""
Restrict an endpoint to users who belong to one or more of the
specified groups.
"""
allowed_group_ids = [grp.getgrnam(g).gr_gid for g in allowed_groups]
@requires_authentication_no_realm
@functools.wraps(f)
def wrapper(_username: str, *args, **kwargs):
# we need to call the argument _username to avoid name clashes with
# the arguments of f
username = _username
if username.startswith('ceod/'):
# ceod services are always allowed to make internal calls
return f(*args, **kwargs)
for gid in os.getgrouplist(username, pwd.getpwnam(username).pw_gid):
if gid in allowed_group_ids:
return f(*args, **kwargs)
logger.debug(
f"User '{username}' denied since they are not in one of {allowed_groups}"
)
return {
'error': f'You must be in one of {allowed_groups}'
}, 403
return wrapper
def authz_restrict_to_staff(f: Callable) -> Callable:
"""A decorator to restrict an endpoint to staff members."""
allowed_groups = ['office', 'staff', 'adm']
return authz_restrict_to_groups(f, allowed_groups)
def authz_restrict_to_syscom(f: Callable) -> Callable:
"""A decorator to restrict an endpoint to syscom members."""
allowed_groups = ['syscom']
return authz_restrict_to_groups(f, allowed_groups)
def create_streaming_response(txn: AbstractTransaction):
"""
Returns a plain text response with one JSON object per line,
indicating the progress of the transaction.
"""
def generate():
generator = txn.execute_iter()
try:
for operation in generator:
yield json.dumps({
'status': 'in progress',
'operation': operation,
}) + '\n'
yield json.dumps({
'status': 'completed',
'result': txn.result,
}) + '\n'
except GeneratorExit:
# Keep on going. Even if the client closes the connection, we don't
# want to give up half way through.
try:
for operation in generator:
pass
except Exception:
logger.warning('Transaction failed:\n' + traceback.format_exc())
txn.rollback()
except Exception as err:
logger.warning('Transaction failed:\n' + traceback.format_exc())
txn.rollback()
yield json.dumps({
'status': 'aborted',
'error': str(err),
}) + '\n'
return current_app.response_class(
stream_with_context(generate()), mimetype='text/plain')
def development_only(f: Callable) -> Callable:
@functools.wraps(f)
def wrapper(*args, **kwargs):
if current_app.config.get('ENV') == 'development' or \
current_app.config.get('TESTING'):
return f(*args, **kwargs)
return {
'error': 'This endpoint may only be called in development'
}, 403
return wrapper
def is_truthy(s: str) -> bool:
return s.lower() in ['yes', 'true']

35
ceod/api/uwldap.py Normal file
View File

@ -0,0 +1,35 @@
from flask import Blueprint, request
from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, ILDAPService
bp = Blueprint('uwldap', __name__)
@bp.route('/<username>')
def get_user(username: str):
uwldap_srv = component.getUtility(IUWLDAPService)
record = uwldap_srv.get_user(username)
if record is None:
return {
'error': 'user not found',
}, 404
return record.to_dict()
@bp.route('/updateprograms', methods=['POST'])
@authz_restrict_to_syscom
def update_programs():
ldap_srv = component.getUtility(ILDAPService)
if request.headers.get('content-type') == 'application/json':
body = request.get_json()
else:
body = {}
kwargs = {'members': body.get('members')}
if body.get('dry_run') or is_truthy(request.args.get('dry_run', 'false')):
kwargs['dry_run'] = True
return jsonify(
ldap_srv.update_programs(**kwargs)
)

88
ceod/db/MySQLService.py Normal file
View File

@ -0,0 +1,88 @@
from zope.interface import implementer
from zope import component
from contextlib import contextmanager
from ceo_common.interfaces import IDatabaseService, IConfig
from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError, UserAlreadyExistsError, \
UserNotFoundError
from ceo_common.logger_factory import logger_factory
from ceod.utils import gen_password
from ceod.db.utils import response_is_empty
from mysql.connector import connect
from mysql.connector.errors import InterfaceError, ProgrammingError
logger = logger_factory(__name__)
@implementer(IDatabaseService)
class MySQLService:
type = 'mysql'
def __init__(self):
config = component.getUtility(IConfig)
self.auth_username = config.get('mysql_username')
self.auth_password = config.get('mysql_password')
self.host = config.get('mysql_host')
@contextmanager
def mysql_connection(self):
try:
with connect(
host=self.host,
user=self.auth_username,
password=self.auth_password,
) as con:
yield con
except InterfaceError as e:
logger.error(e)
raise DatabaseConnectionError()
except ProgrammingError as e:
logger.error(e)
raise DatabasePermissionError()
def create_db(self, username: str) -> str:
password = gen_password()
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
search_for_db = f"SHOW DATABASES LIKE '{username}'"
create_user = f"""
CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s;
"""
create_database = f"""
CREATE DATABASE {username};
GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'%';
"""
with self.mysql_connection() as con, con.cursor() as cursor:
if response_is_empty(search_for_user, con):
cursor.execute(create_user, {'password': password})
if response_is_empty(search_for_db, con):
cursor.execute(create_database)
else:
raise UserAlreadyExistsError()
return password
def reset_db_passwd(self, username: str) -> str:
password = gen_password()
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
reset_password = f"""
ALTER USER '{username}'@'%' IDENTIFIED BY %(password)s
"""
with self.mysql_connection() as con, con.cursor() as cursor:
if not response_is_empty(search_for_user, con):
cursor.execute(reset_password, {'password': password})
else:
raise UserNotFoundError(username)
return password
def delete_db(self, username: str):
drop_db = f"DROP DATABASE IF EXISTS {username}"
drop_user = f"""
DROP USER IF EXISTS '{username}'@'%';
"""
with self.mysql_connection() as con, con.cursor() as cursor:
cursor.execute(drop_db)
cursor.execute(drop_user)

View File

@ -0,0 +1,86 @@
from zope.interface import implementer
from zope import component
from contextlib import contextmanager
from ceo_common.interfaces import IDatabaseService, IConfig
from ceo_common.errors import DatabaseConnectionError, DatabasePermissionError, \
UserAlreadyExistsError, UserNotFoundError
from ceo_common.logger_factory import logger_factory
from ceod.utils import gen_password
from ceod.db.utils import response_is_empty
from psycopg2 import connect, OperationalError, ProgrammingError
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
logger = logger_factory(__name__)
@implementer(IDatabaseService)
class PostgreSQLService:
type = 'postgresql'
def __init__(self):
config = component.getUtility(IConfig)
self.auth_username = config.get('postgresql_username')
self.auth_password = config.get('postgresql_password')
self.host = config.get('postgresql_host')
@contextmanager
def psql_connection(self):
con = None
try:
# Don't use the connection as a context manager, because that
# creates a new transaction.
con = connect(
host=self.host,
user=self.auth_username,
password=self.auth_password,
)
con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
yield con
except OperationalError as e:
logger.error(e)
raise DatabaseConnectionError()
except ProgrammingError as e:
logger.error(e)
raise DatabasePermissionError()
finally:
if con is not None:
con.close()
def create_db(self, username: str) -> str:
password = gen_password()
search_for_user = f"SELECT FROM pg_roles WHERE rolname='{username}'"
search_for_db = f"SELECT FROM pg_database WHERE datname='{username}'"
create_user = f"CREATE USER {username} WITH PASSWORD %(password)s"
create_database = f"CREATE DATABASE {username} OWNER {username}"
revoke_perms = f"REVOKE ALL ON DATABASE {username} FROM PUBLIC"
with self.psql_connection() as con, con.cursor() as cursor:
if not response_is_empty(search_for_user, con):
raise UserAlreadyExistsError()
cursor.execute(create_user, {'password': password})
if response_is_empty(search_for_db, con):
cursor.execute(create_database)
cursor.execute(revoke_perms)
return password
def reset_db_passwd(self, username: str) -> str:
password = gen_password()
search_for_user = f"SELECT FROM pg_roles WHERE rolname='{username}'"
reset_password = f"ALTER USER {username} WITH PASSWORD %(password)s"
with self.psql_connection() as con, con.cursor() as cursor:
if response_is_empty(search_for_user, con):
raise UserNotFoundError(username)
cursor.execute(reset_password, {'password': password})
return password
def delete_db(self, username: str):
drop_db = f"DROP DATABASE IF EXISTS {username}"
drop_user = f"DROP USER IF EXISTS {username}"
with self.psql_connection() as con, con.cursor() as cursor:
cursor.execute(drop_db)
cursor.execute(drop_user)

2
ceod/db/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .MySQLService import MySQLService
from .PostgreSQLService import PostgreSQLService

5
ceod/db/utils.py Normal file
View File

@ -0,0 +1,5 @@
def response_is_empty(query: str, connection) -> bool:
with connection.cursor() as cursor:
cursor.execute(query)
response = cursor.fetchall()
return len(response) == 0

79
ceod/model/FileService.py Normal file
View File

@ -0,0 +1,79 @@
import os
import shutil
from typing import List
from zope import component
from zope.interface import implementer
from .validators import is_valid_forwarding_address, InvalidForwardingAddressException
from ceo_common.interfaces import IFileService, IConfig, IUser
@implementer(IFileService)
class FileService:
def __init__(self):
cfg = component.getUtility(IConfig)
self.member_home_skel = cfg.get('members_skel')
self.club_home_skel = cfg.get('clubs_skel')
def create_home_dir(self, user: IUser):
if user.is_club():
skel_dir = self.club_home_skel
else:
skel_dir = self.member_home_skel
home = user.home_directory
# It's important to NOT use pwd here because if the user was recently
# deleted (e.g. as part of a rolled back transaction), their old UID
# and GID numbers will still be in the NSS cache.
uid = user.uid_number
gid = user.gid_number
# recursively copy skel dir to user's home
shutil.copytree(skel_dir, home)
# Set ownership and permissions on user's home.
# The setgid bit ensures that all files created under that
# directory belong to the owner (useful for clubs).
os.chmod(home, mode=0o2751) # rwxr-s--x
os.chown(home, uid=uid, gid=gid)
# recursively set file ownership
for root, dirs, files in os.walk(home):
for dir in dirs:
os.chown(os.path.join(root, dir), uid=uid, gid=gid)
for file in files:
os.chown(os.path.join(root, file), uid=uid, gid=gid)
def delete_home_dir(self, user: IUser):
shutil.rmtree(user.home_directory)
def get_forwarding_addresses(self, user: IUser) -> List[str]:
forward_file = os.path.join(user.home_directory, '.forward')
if not os.path.isfile(forward_file):
return []
lines = [
line.strip() for line in open(forward_file).readlines()
]
return [
line for line in lines
if line != '' and line[0] != '#'
]
def set_forwarding_addresses(self, user: IUser, addresses: List[str]):
for line in addresses:
if not is_valid_forwarding_address(line):
raise InvalidForwardingAddressException(line)
uid = user.uid_number
gid = user.gid_number
forward_file = os.path.join(user.home_directory, '.forward')
if os.path.exists(forward_file):
# create a backup
backup_forward_file = forward_file + '.bak'
shutil.copyfile(forward_file, backup_forward_file)
os.chown(backup_forward_file, uid=uid, gid=gid)
else:
# create a new ~/.forward file
open(forward_file, 'w')
os.chown(forward_file, uid=uid, gid=gid)
with open(forward_file, 'w') as f:
for line in addresses:
f.write(line + '\n')

Some files were not shown because too many files have changed in this diff Show More