forked from public/pyceo
Compare commits
67 Commits
Author | SHA1 | Date |
---|---|---|
Andrew Wang | ba50a39700 | |
Andrew Wang | eb5d632606 | |
Max Erenberg | 7d23fd690f | |
Max Erenberg | d8e5b1f1d4 | |
Max Erenberg | 46881f7a1f | |
Max Erenberg | e011e98026 | |
Max Erenberg | 95e167578f | |
Max Erenberg | 51737585bd | |
Max Erenberg | 831ebf17aa | |
Max Erenberg | 45192d75bf | |
Max Erenberg | e851c77e74 | |
Max Erenberg | 08a3faaefc | |
Max Erenberg | 7a8751fd8f | |
Max Erenberg | 6917247fdd | |
Rio Liu | ad937eebeb | |
Max Erenberg | 0974a7471b | |
Max Erenberg | 0783588323 | |
Max Erenberg | 7142659a8c | |
Max Erenberg | 862dfc01b2 | |
Max Erenberg | bb82945b41 | |
Max Erenberg | 38f354c106 | |
Max Erenberg | 95d083fca1 | |
Max Erenberg | 89e6c541ab | |
Max Erenberg | c39eff6ca7 | |
Max Erenberg | e4970bf008 | |
Max Erenberg | d11c6af2ec | |
Max Erenberg | 4783621d22 | |
Max Erenberg | 14273dcbe6 | |
Max Erenberg | 14c058eb67 | |
Max Erenberg | dc09210d23 | |
Max Erenberg | 583fcded9b | |
Max Erenberg | 46fd926acc | |
Max Erenberg | 490abb302c | |
Max Erenberg | 26fd8f6f68 | |
Max Erenberg | 2a286579cb | |
Max Erenberg | ecf089c261 | |
Max Erenberg | cc0bc4a638 | |
Max Erenberg | 2273ffa241 | |
Max Erenberg | 12a83ce4c0 | |
Max Erenberg | 28c55b2fed | |
Max Erenberg | 448692018a | |
Max Erenberg | 6bf4d75a60 | |
Max Erenberg | 5bda74eaf9 | |
Max Erenberg | df5d9e5f14 | |
Max Erenberg | 57ab275634 | |
Max Erenberg | e370035b25 | |
Max Erenberg | d78d31eec0 | |
Max Erenberg | dd59bea918 | |
Max Erenberg | d82b5a763b | |
Max Erenberg | 6cdb41d47b | |
Max Erenberg | cbf4aa43f8 | |
Max Erenberg | 9e4d564a33 | |
Max Erenberg | 3ecf43731f | |
Max Erenberg | e7bfe36c0b | |
Max Erenberg | 87298e18b3 | |
Max Erenberg | baeb83b1e2 | |
Max Erenberg | 4a312378b7 | |
Max Erenberg | 96cb2bc808 | |
Max Erenberg | 7c67a07200 | |
Max Erenberg | c32e565f68 | |
Max Erenberg | da14764687 | |
Max Erenberg | ff2ac95d5e | |
Max Erenberg | 9227552b29 | |
Max Erenberg | 7b749701f0 | |
Max Erenberg | e966e3f307 | |
Max Erenberg | 3b78b7ffb4 | |
Max Erenberg | de0f473881 |
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
BASE dc=csclub,dc=internal
|
||||
URI ldap://auth1.csclub.internal
|
||||
SUDOERS_BASE ou=SUDOers,dc=csclub,dc=internal
|
|
@ -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
|
|
@ -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/
|
|
@ -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 )
|
||||
|
|
@ -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
|
|
@ -1,4 +0,0 @@
|
|||
[DEFAULT]
|
||||
sign-tags = True
|
||||
posttag = git push /users/git/public/pyceo.git --tags
|
||||
debian-tag=v%(version)s
|
|
@ -1,5 +1,7 @@
|
|||
/build-stamp
|
||||
/build
|
||||
__pycache__/
|
||||
*.pyc
|
||||
/build-ceo
|
||||
/build-ceod
|
||||
/venv/
|
||||
.vscode/
|
||||
*.o
|
||||
*.so
|
||||
.idea/
|
||||
|
|
|
@ -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
|
||||
```
|
|
@ -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
40
bin/ceo
|
@ -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()
|
5
build.sh
5
build.sh
|
@ -1,5 +0,0 @@
|
|||
if test -e .git; then
|
||||
git-buildpackage --git-ignore-new -us -uc
|
||||
else
|
||||
debuild -us -uc
|
||||
fi
|
|
@ -1 +0,0 @@
|
|||
/ceo_pb2.py
|
|
@ -1 +0,0 @@
|
|||
"""CSC Electronic Office"""
|
|
@ -0,0 +1,4 @@
|
|||
from .cli import cli
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli(obj={})
|
|
@ -0,0 +1 @@
|
|||
from .entrypoint import cli
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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()
|
162
ceo/conf.py
162
ceo/conf.py
|
@ -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))
|
|
@ -1 +0,0 @@
|
|||
"""Console Interface"""
|
|
@ -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)
|
|
@ -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)
|
|
@ -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 ''
|
|
@ -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]
|
||||
)
|
|
@ -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
|
||||
|
|
@ -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)
|
13
ceo/excep.py
13
ceo/excep.py
|
@ -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)
|
|
@ -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)
|
148
ceo/ldapi.py
148
ceo/ldapi.py
|
@ -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 ''
|
609
ceo/members.py
609
ceo/members.py
|
@ -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'
|
54
ceo/mysql.py
54
ceo/mysql.py
|
@ -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)
|
||||
|
|
@ -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',
|
||||
}
|
24
ceo/ops.py
24
ceo/ops.py
|
@ -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
|
155
ceo/pymazon.py
155
ceo/pymazon.py
|
@ -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)
|
|
@ -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
|
254
ceo/terms.py
254
ceo/terms.py
|
@ -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()
|
42
ceo/test.py
42
ceo/test.py
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
"""Urwid User Interface"""
|
|
@ -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()
|
|
@ -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)))
|
|
@ -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()
|
|
@ -1,8 +0,0 @@
|
|||
import os
|
||||
from ceo.urwid.window import *
|
||||
|
||||
|
||||
def library(data):
|
||||
os.system("librarian")
|
||||
ui.stop()
|
||||
ui.start()
|
|
@ -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()
|
|
@ -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'])
|
|
@ -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()
|
|
@ -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()
|
|
@ -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)]))
|
||||
|
||||
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -1,8 +0,0 @@
|
|||
def uri():
|
||||
return "ldap://uwldap.uwaterloo.ca/"
|
||||
|
||||
def base():
|
||||
return "dc=uwaterloo,dc=ca"
|
||||
|
||||
def domain():
|
||||
return 'uwaterloo.ca'
|
|
@ -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')
|
|
@ -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."""
|
|
@ -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"""
|
|
@ -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."""
|
|
@ -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."""
|
|
@ -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."""
|
|
@ -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."""
|
|
@ -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.
|
||||
"""
|
|
@ -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.
|
||||
"""
|
|
@ -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."""
|
|
@ -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']
|
||||
"""
|
|
@ -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.
|
||||
"""
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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())
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
from .Config import Config
|
||||
from .HTTPClient import HTTPClient
|
||||
from .RemoteMailmanService import RemoteMailmanService
|
||||
from .Term import Term
|
|
@ -0,0 +1 @@
|
|||
from .app_factory import create_app
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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'}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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']
|
|
@ -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)
|
||||
)
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,2 @@
|
|||
from .MySQLService import MySQLService
|
||||
from .PostgreSQLService import PostgreSQLService
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue