Compare commits

..

1 Commits

Author SHA1 Message Date
Raymond Li 266828ec21 Bump version for release
continuous-integration/drone/push Build is passing Details
2022-08-05 21:40:33 -04:00
144 changed files with 1166 additions and 6748 deletions

View File

@ -5,34 +5,34 @@ name: default
steps: steps:
# use the step name to mock out the gethostname() call in our tests # use the step name to mock out the gethostname() call in our tests
- name: phosphoric-acid - name: phosphoric-acid
image: python:3.9-slim-bullseye image: python:3.7-buster
# unfortunately we have to do everything in one step because there's no # unfortunately we have to do everything in one step because there's no
# way to share system packages between steps # way to share system packages between steps
commands: commands:
# install dependencies # install dependencies
- apt update - apt update && apt install -y libkrb5-dev libpq-dev python3-dev libaugeas0
- apt install --no-install-recommends -y gcc libkrb5-dev libaugeas0
- python3 -m venv venv - python3 -m venv venv
- . venv/bin/activate - . venv/bin/activate
- venv/bin/pip install -r dev-requirements.txt -r requirements.txt - pip install -r dev-requirements.txt
- pip install -r requirements.txt
# lint # lint
- flake8 - flake8
# unit + integration tests # unit + integration tests
- bash -c ". .drone/phosphoric-acid-setup.sh && IMAGE__setup && CONTAINER__setup" - .drone/phosphoric-acid-setup.sh
- pytest -v - pytest -v
services: services:
- name: auth1 - name: auth1
image: debian:bullseye-slim image: debian:buster
commands: commands:
- bash -c ". .drone/auth1-setup.sh && IMAGE__setup && CONTAINER__setup" - .drone/auth1-setup.sh
- sleep infinity - sleep infinity
- name: coffee - name: coffee
image: debian:bullseye-slim image: debian:buster
commands: commands:
- bash -c ". .drone/coffee-setup.sh && IMAGE__setup && CONTAINER__setup" - .drone/coffee-setup.sh
- sleep infinity - sleep infinity
trigger: trigger:

View File

@ -1,17 +0,0 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

View File

@ -4,128 +4,103 @@ set -ex
. .drone/common.sh . .drone/common.sh
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1
if [ -n "$CI" ]; then
# 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
fi
apt install -y psmisc
# If we don't do this then OpenLDAP uses a lot of RAM # If we don't do this then OpenLDAP uses a lot of RAM
ulimit -n 1024 ulimit -n 1024
CONTAINER__fix_hosts() { # LDAP
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1 apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
if [ -n "$CI" ]; then # `service slapd stop` doesn't seem to work
# I'm not sure why, but we also need to remove the hosts entry for the killall slapd || true
# container's real hostname, otherwise slapd only looks for the principal service nslcd stop || true
# ldap/<container hostname> (this is with the sasl-host option) rm -rf /etc/ldap/slapd.d
sed -E "/\\b$(hostname)\\b/d" /etc/hosts > /tmp/hosts rm /var/lib/ldap/*
cat /tmp/hosts > /etc/hosts cp /usr/share/slapd/DB_CONFIG /var/lib/ldap/DB_CONFIG
rm /tmp/hosts cp .drone/slapd.conf /etc/ldap/slapd.conf
fi 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:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y vim ldapvi
echo 'export EDITOR=vim' >> /root/.bashrc
echo 'alias ldapvi="ldapvi -Y EXTERNAL -h ldapi:///"' >> /root/.bashrc
fi
IMAGE__setup_ldap() { # KERBEROS
# In the "slim" Docker images, /usr/share/doc/* is excluded by default apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
echo 'path-include /usr/share/doc/sudo-ldap/schema.OpenLDAP' > /etc/dpkg/dpkg.cfg.d/zz-ceo service krb5-admin-server stop || true
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap service krb5-kdc stop || true
# `service slapd stop` doesn't seem to work service saslauthd stop || true
killall slapd || true cp .drone/krb5.conf /etc/krb5.conf
service nslcd stop || true cp .drone/kdc.conf /etc/krb5kdc.conf
rm -rf /etc/ldap/slapd.d echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm /var/lib/ldap/* rm -f /var/lib/krb5kdc/*
cp .drone/slapd.conf /etc/ldap/slapd.conf echo -e 'krb5\nkrb5' | krb5_newrealm
cp .drone/ldap.conf /etc/ldap/ldap.conf service krb5-kdc start
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema service krb5-admin-server start
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/ rm -f /etc/krb5.keytab
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/ cat <<EOF | kadmin.local
sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \
echo 'export EDITOR=vim' >> /root/.bashrc
grep -q 'alias ldapvi' /root/.bashrc || \
echo 'alias ldapvi="ldapvi -Y EXTERNAL -h ldapi:///"' >> /root/.bashrc
fi
}
IMAGE__setup_krb5() {
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 addpol -minlength 4 default
addprinc -pw krb5 sysadmin/admin addprinc -pw krb5 sysadmin/admin
addprinc -randkey ceod/admin addprinc -pw krb5 ctdalek
addprinc -pw krb5 exec1
addprinc -pw krb5 regular1
addprinc -pw krb5 office1
addprinc -randkey host/auth1.csclub.internal addprinc -randkey host/auth1.csclub.internal
addprinc -randkey ldap/auth1.csclub.internal addprinc -randkey ldap/auth1.csclub.internal
ktadd host/auth1.csclub.internal ktadd host/auth1.csclub.internal
ktadd ldap/auth1.csclub.internal ktadd ldap/auth1.csclub.internal
EOF EOF
# Add all of the people defined in data.ldif groupadd keytab || true
for princ in ctdalek exec1 regular1 office1; do chgrp keytab /etc/krb5.keytab
echo "addprinc -pw krb5 $princ" | kadmin.local chmod 640 /etc/krb5.keytab
done usermod -a -G keytab openldap
groupadd keytab || true usermod -a -G sasl openldap
chgrp keytab /etc/krb5.keytab cat <<EOF > /usr/lib/sasl2/slapd.conf
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 mech_list: plain login gssapi external
pwcheck_method: saslauthd pwcheck_method: saslauthd
EOF EOF
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
} service saslauthd start
while true; do
IMAGE__setup() { killall slapd
# slapd needs /etc/hosts to be setup properly sleep 1
CONTAINER__fix_resolv_conf if service slapd start; then
CONTAINER__fix_hosts break
apt update
# for the 'killall' command
apt install -y psmisc
IMAGE__setup_ldap
IMAGE__setup_krb5
IMAGE__common_setup
service slapd stop || true
killall slapd || true
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
local started_slapd=false
for i in {1..5}; do
if service slapd start; then
started_slapd=true
break
fi
sleep 1
done
if [ $started_slapd != "true" ]; then
echo "Failed to start slapd" >&2
return 1
fi fi
service krb5-admin-server start done
service krb5-kdc start
service saslauthd start # sync with phosphoric-acid
service nslcd start nc -l 0.0.0.0 9000 &
# Let other containers know that we're ready if [ -z "$CI" ]; then
nc -l -k 0.0.0.0 9000 & # sync with coffee
} nc -l 0.0.0.0 9001 &
# sync with mail
nc -l 0.0.0.0 9002 &
fi

View File

@ -4,28 +4,25 @@ set -ex
. .drone/common.sh . .drone/common.sh
CONTAINER__fix_hosts() { # set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
add_fqdn_to_hosts $(get_ip_addr auth1) auth1 add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
IMAGE__setup() { apt install --no-install-recommends -y default-mysql-server postgresql
IMAGE__ceod_setup
apt install --no-install-recommends -y default-mysql-server postgresql
# MYSQL # MYSQL
service mariadb stop service mysql stop
sed -E -i 's/^(bind-address[[:space:]]+= 127\.0\.0\.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf sed -E -i 's/^(bind-address[[:space:]]+= 127.0.0.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mariadb start service mysql start
cat <<EOF | mysql cat <<EOF | mysql
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql'; CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION; GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
EOF EOF
# POSTGRESQL # POSTGRESQL
service postgresql stop service postgresql stop
local POSTGRES_DIR=/etc/postgresql/*/main POSTGRES_DIR=/etc/postgresql/11/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD # TYPE DATABASE USER ADDRESS METHOD
local all postgres peer local all postgres peer
host all postgres localhost md5 host all postgres localhost md5
@ -39,29 +36,19 @@ local sameuser all peer
host sameuser all 0.0.0.0/0 md5 host sameuser all 0.0.0.0/0 md5
host sameuser all ::/0 md5 host sameuser all ::/0 md5
EOF EOF
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \ grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
service postgresql start service postgresql start
su -c " su -c "
cat <<EOF | psql cat <<EOF | psql
ALTER USER postgres WITH PASSWORD 'postgres'; ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public; REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO postgres;
EOF" postgres EOF" postgres
service mariadb stop || true if [ -z "$CI" ]; then
service postgresql stop || true auth_setup coffee
} fi
CONTAINER__setup() { # sync with phosphoric-acid
CONTAINER__fix_resolv_conf nc -l 0.0.0.0 9000 &
CONTAINER__fix_hosts
CONTAINER__ceod_setup
if [ -z "$CI" ]; then
CONTAINER__auth_setup coffee
fi
service mariadb start
service postgresql start
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

View File

@ -1,109 +1,58 @@
# TODO: fix Drone
chmod 1777 /tmp
# 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
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
# netcat is used for synchronization between the containers
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt update
# The IMAGE__ functions should be called when building the image. apt install -y netcat-openbsd
# The CONTAINER__ functions should be called when running an instance of the if [ "$(hostname)" != auth1 ]; then
# image in a container.
IMAGE__auth_setup() {
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
mkdir -p /etc/ldap
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
}
IMAGE__common_setup() {
apt update
# netcat is used for synchronization between the containers
apt install -y netcat-openbsd
IMAGE__auth_setup
}
IMAGE__ceod_setup() {
IMAGE__common_setup
# ceod uses Augeas, which is not installed by default in the Python # ceod uses Augeas, which is not installed by default in the Python
# Docker container # Docker container
apt install -y libaugeas0 apt install -y libaugeas0
} fi
CONTAINER__fix_resolv_conf() {
# don't resolve container names to *real* CSC machines
sed -E 's/([[:alnum:]-]+\.)*uwaterloo\.ca//g' /etc/resolv.conf > /tmp/resolv.conf
# remove empty 'search' lines, if we created them
sed -E -i '/^search[[:space:]]*$/d' /tmp/resolv.conf
# also remove the 'rotate' option, since this can cause the Docker DNS server
# to be circumvented
sed -E -i '/^options.*\brotate/d' /tmp/resolv.conf
# we can't replace /etc/resolv.conf using 'mv' because it's mounted into the container
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
}
CONTAINER__auth_setup() {
local hostname=$1
sync_with auth1
service nslcd start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
}
CONTAINER__ceod_setup() {
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
}
# Common utility functions
get_ip_addr() { get_ip_addr() {
# There appears to be a bug in newer versions of Podman where using both getent hosts $1 | cut -d' ' -f1
# --name and --hostname causes a container to have two identical DNS
# entries, which causes `getent hosts` to print two lines.
# So we use `head -n 1` to select just the first line.
getent hosts $1 | head -n 1 | cut -d' ' -f1
} }
add_fqdn_to_hosts() { add_fqdn_to_hosts() {
local ip_addr=$1 ip_addr=$1
local hostname=$2 hostname=$2
sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts
# we can't replace /etc/hosts using 'mv' because it's mounted into the container
cp /tmp/hosts /etc/hosts cp /tmp/hosts /etc/hosts
rm /tmp/hosts rm /tmp/hosts
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
} }
sync_with() { sync_with() {
local host=$1 host=$1
local port=9000 port=9000
local synced=false if [ $# -eq 2 ]; then
port=$2
fi
synced=false
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph) # give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
for i in {1..240}; do for i in {1..240}; do
if nc -vz $host $port ; then if nc -vz $host $port ; then
@ -114,3 +63,45 @@ sync_with() {
done done
test $synced = true test $synced = true
} }
auth_setup() {
hostname=$1
# 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
if [ $hostname = phosphoric-acid ]; then
sync_port=9000
elif [ $hostname = coffee ]; then
sync_port=9001
else
sync_port=9002
fi
sync_with auth1 $sync_port
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
if [ $hostname = phosphoric-acid ]; then
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey ceod/admin
ktadd ceod/admin
EOF
fi
service nslcd start
}

View File

@ -186,28 +186,3 @@ objectClass: group
objectClass: posixGroup objectClass: posixGroup
cn: office1 cn: office1
gidNumber: 20004 gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

View File

@ -4,27 +4,20 @@ set -ex
. .drone/common.sh . .drone/common.sh
CONTAINER__fix_hosts() { # set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail
add_fqdn_to_hosts $(get_ip_addr auth1) auth1 add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
IMAGE__setup() { . venv/bin/activate
IMAGE__ceod_setup python -m tests.MockMailmanServer &
} python -m tests.MockSMTPServer &
python -m tests.MockCloudStackServer &
python -m tests.MockHarborServer &
CONTAINER__setup() { auth_setup mail
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts # for the VHostManager
CONTAINER__ceod_setup mkdir -p /run/ceod/member-vhosts
CONTAINER__auth_setup mail
# for the VHostManager # sync with phosphoric-acid
mkdir -p /run/ceod/member-vhosts nc -l 0.0.0.0 9000 &
# mock services
venv/bin/python -m tests.MockMailmanServer &
venv/bin/python -m tests.MockSMTPServer &
venv/bin/python -m tests.MockCloudStackServer &
venv/bin/python -m tests.MockHarborServer &
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

View File

@ -1,10 +0,0 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

View File

@ -4,44 +4,29 @@ set -ex
. .drone/common.sh . .drone/common.sh
CONTAINER__fix_hosts() { # set FQDN in /etc/hosts
add_fqdn_to_hosts "$(get_ip_addr $(hostname))" phosphoric-acid 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 auth1)" auth1
add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee
# mail container doesn't run in CI # mail container doesn't run in CI
if [ -z "$CI" ]; then if [ -z "$CI" ]; then
add_fqdn_to_hosts $(get_ip_addr mail) mail add_fqdn_to_hosts $(get_ip_addr mail) mail
fi fi
}
CONTAINER__setup_userdirs() { auth_setup phosphoric-acid
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
# create directories for users # initialize the skel directory
for user in ctdalek regular1 exec1; do shopt -s dotglob
mkdir -p /users/$user mkdir -p /users/skel
chown $user:$user /users/$user cp /etc/skel/* /users/skel/
done
}
IMAGE__setup() { # create directories for users
IMAGE__ceod_setup for user in ctdalek regular1 exec1; do
# git is required by the ClubWebHostingService mkdir -p /users/$user
apt install --no-install-recommends -y git chown $user:$user /users/$user
} done
CONTAINER__setup() { sync_with coffee
CONTAINER__fix_resolv_conf if [ -z "$CI" ]; then
CONTAINER__fix_hosts sync_with mail
CONTAINER__ceod_setup fi
CONTAINER__auth_setup phosphoric-acid
CONTAINER__setup_userdirs
echo "ktadd ceod/admin" | kadmin -p sysadmin/admin -w krb5
sync_with coffee
if [ -z "$CI" ]; then
sync_with mail
fi
}

View File

@ -7,7 +7,6 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid pidfile /var/run/slapd/slapd.pid
@ -41,11 +40,6 @@ access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access # systems committee get full access
access to * access to *
by dn="cn=ceod,dc=csclub,dc=internal" write by dn="cn=ceod,dc=csclub,dc=internal" write

View File

@ -106,18 +106,3 @@ objectClass: person
objectClass: top objectClass: top
uid: exec3 uid: exec3
mail: exec3@uwaterloo.internal mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

View File

@ -1,5 +0,0 @@
#!/bin/bash
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
exit $?

4
.gitignore vendored
View File

@ -1,10 +1,6 @@
# If you update this file, please also update the extend-diff-ignore option # If you update this file, please also update the extend-diff-ignore option
# in debian/source/options. # in debian/source/options.
*.key
*.gpg
*.pgp
__pycache__/ __pycache__/
/venv/ /venv/
/dist/ /dist/

View File

@ -9,16 +9,11 @@ Make sure your GPG key is in /srv/debian/gpg on potassium-benzoate. See
[here](https://wiki.csclub.uwaterloo.ca/Debian_Repository#Step_1:_Add_to_Uploaders) [here](https://wiki.csclub.uwaterloo.ca/Debian_Repository#Step_1:_Add_to_Uploaders)
for instructions. for instructions.
Make sure you are in the `csc-mirror` group too.
## Creating the package ## Creating the package
Use Docker/Podman to avoid screwing up your main system. Use Docker/Podman to avoid screwing up your main system.
For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker): For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker):
```sh ```sh
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm bash podman run -it --name pyceo-packaging -v "$PWD":"$PWD" -w "$PWD" debian:bullseye bash
# if disconnected from shell, reconnect with:
podman start pyceo-packaging
podman exec -it pyceo-packaging bash
``` ```
**Important**: Make sure to use a container image for the same distribution which you're packaging. **Important**: Make sure to use a container image for the same distribution which you're packaging.
For example, if you're creating a package for bullseye, you should be using the debian:bullseye For example, if you're creating a package for bullseye, you should be using the debian:bullseye
@ -29,39 +24,22 @@ Here are some of the prerequisites you'll need to build the deb files
```sh ```sh
apt update apt update
apt install -y devscripts debhelper git-buildpackage vim apt install -y devscripts debhelper git-buildpackage vim
apt install -y python3-dev python3-venv libkrb5-dev libpq-dev libaugeas0 scdoc # dependencies for building ceo
``` ```
Make sure to also install all of the packages in the 'Build-Depends' section in debian/control. Make sure to also install all of the packages in the 'Build-Depends' section in debian/control.
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`). Update VERSION.txt to the next version, and do a git commit.
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes). Now run `dch -i` and edit the changelog.
Now you will build a signed package. Place your key ID after the `-k` argument, e.g. Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
```sh ```sh
# (pre-requisite) if container doesn't have your gpg key gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC
## step 1: export from host/another computer with your keyring
gpg --armor --output private.key --export-secret-key <your pgp key's id email>
## step 2: import into build container
gpg --import private.key
## step 3: find your key's public key
gpg --list-secret-keys # get key id
## step 4: trust ids (before building)
gpg --edit <pub key id>
gpg> trust # run when gpg editing prompt appears
> 5 # "ultimate" trust
gpg> save # gpg will report no changes were made, but trust of ids should be changed
# alternatively, sign with `debsign` after creating unsigned package
# build (signed) package
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC --lintian-opts --no-lintian
``` ```
This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory. This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory.
Now do another git commit (since you edited the changelog file). Now do another git commit (since you edited the changelog file).
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**): To clean the packages:
```sh ```sh
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb} rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
``` ```
@ -69,30 +47,28 @@ rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
## Uploading the package ## Uploading the package
Inside the container, go up one directory, and create a tarball with all the package files: Inside the container, go up one directory, and create a tarball with all the package files:
``` ```
cd .. # within the container, generated files are in the parent directory of your git repo cd ..
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb} tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
``` ```
Outside of the container (i.e. on your personal machine), copy the tarball out of the Outside of the container (i.e. on your personal machine), copy the tarball out of the
container into your current directory, e.g. container into your current directory, e.g.
``` ```
podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz . podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
# or generally, if you're in the pyceo repo:
podman cp pyceo-packaging:$(cd ../ && pwd)/pyceo.tar.gz .
``` ```
(Replace `/home/max/repos` by the directory in the container with the tarball.) (Replace `/home/max/repos` by the directory in the container with the tarball.)
Now upload the tarball to a CSC machine, e.g. Now upload the tarball to a CSC machine, e.g.
``` ```
# on "HOST" machine scp pyceo.tar.gz mannitol:~/
scp pyceo.tar.gz mannitol:~
``` ```
SSH into that machine and extract the tarball into a separate directory: SSH into that machine and extract the tarball into a separate directory:
``` ```
ssh mannitol ssh mannitol
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent mkdir pyceo-parent
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb} mv pyceo.tar.gz pyceo-parent/
cd pyceo-parent
tar zxvf pyceo.tar.gz tar zxvf pyceo.tar.gz
``` ```
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage). At this point, you will need a dupload.conf file. Ask someone on syscom for a copy.
Now upload the package to potassium-benzoate: Now upload the package to potassium-benzoate:
``` ```
@ -102,21 +78,7 @@ dupload *.changes
Now SSH into potassium-benzoate and run the following: Now SSH into potassium-benzoate and run the following:
``` ```
# note: this is AUTOMATICALLY done (within 10-20 minutes by a cron job)
sudo /srv/debian/bin/rrr-incoming sudo /srv/debian/bin/rrr-incoming
``` ```
To check if mirror has accepted the new package, visit: http://debian.csclub.uwaterloo.ca/dists/bookworm/
<<<<<<< Updated upstream
=======
To update CEO:
```
# repeat this for all systems, starting from ceod servers
sudo apt update
# NOTE: be careful of changing configs!!
sudo apt install --only-upgrade ceod
````
>>>>>>> Stashed changes
There, that wasn't so bad...right? :') There, that wasn't so bad...right? :')

View File

@ -10,29 +10,17 @@ overview of its architecture.
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html). The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
## Development ## Development
### Podman ### Docker
If you are not modifying code related to email or Mailman, then you may use If you are not modifying code related to email or Mailman, then you may use
Podman containers instead, which are much easier to work with than the VM. Docker containers instead, which are much easier to work with than the VM.
If you are using Podman, make sure to set the `DOCKER_HOST` environment variable First, make sure you create the virtualenv:
if you have not done so already:
```bash
# Add the following to e.g. your ~/.bashrc
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
```
The Podman socket also needs to be running:
```bash
# Enabled by default on Debian, but not on Fedora
systemctl --user enable --now podman.socket
```
First, create the container images:
```sh ```sh
scripts/build-all-images.sh docker run --rm -v "$PWD:$PWD" -w "$PWD" python:3.7-buster sh -c 'apt update && apt install -y libaugeas0 && python -m venv venv && . venv/bin/activate && pip install -r requirements.txt -r dev-requirements.txt'
``` ```
Then bring up the containers: Then bring up the containers:
```sh ```sh
docker-compose up -d docker-compose up -d # or without -d to run in the foreground
``` ```
This will create some containers with the bare minimum necessary for ceod to This will create some containers with the bare minimum necessary for ceod to
run, and start ceod on each of phosphoric-acid, mail, and coffee container. run, and start ceod on each of phosphoric-acid, mail, and coffee container.
@ -195,12 +183,12 @@ replaced by coffee).
To run ceod on a single host (as root, since the app needs to read the keytab): To run ceod on a single host (as root, since the app needs to read the keytab):
```sh ```sh
export FLASK_APP=ceod.api export FLASK_APP=ceod.api
export FLASK_DEBUG=true export FLASK_ENV=development
flask run -h 0.0.0.0 -p 9987 flask run -h 0.0.0.0 -p 9987
``` ```
Sometimes changes you make in the source code don't show up while Flask Sometimes changes you make in the source code don't show up while Flask
is running. Stop the flask app (Ctrl-C), run `scripts/clear_cache.sh`, then is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
restart the app. restart the app.
## Interacting with the application ## Interacting with the application
@ -226,23 +214,14 @@ curl -V
``` ```
Your should see 'SPNEGO' in the 'Features' section. Your should see 'SPNEGO' in the 'Features' section.
Here's an example of making a request to add a user (in the Docker container): Here's an example of making a request to an endpoint which writes to LDAP:
```sh ```sh
# If you're root, switch to the ctdalek user first # Get a Kerberos TGT first
su ctdalek
# Get a Kerberos TGT (password is krb5)
kinit kinit
# Make the request # Make the request
curl --negotiate -u : --service-name ceod --delegation always \ curl --negotiate -u : --service-name ceod --delegation always \
-d '{"uid":"test_1","cn":"Test One","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \ -d '{"uid":"test_1","cn":"Test One","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \
-X POST http://phosphoric-acid:9987/api/members -X POST http://phosphoric-acid:9987/api/members
# To delete the user:
curl --negotiate -u : --service-name ceod --delegation always \
-X DELETE http://phosphoric-acid:9987/api/members/test_1
# In prod, use the following base URL instead:
# https://phosphoric-acid.csclub.uwaterloo.ca:9987
``` ```
## Packaging ## Packaging

View File

@ -1 +1 @@
1.0.31 1.0.22

View File

@ -1,4 +1,5 @@
import os import os
import socket
import sys import sys
from zope import component from zope import component
@ -8,7 +9,6 @@ from .krb_check import krb_check
from .tui.start import main as tui_main from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services(): def register_services():
@ -19,7 +19,8 @@ def register_services():
if 'CEO_CONFIG' in os.environ: if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG'] config_file = os.environ['CEO_CONFIG']
else: else:
if is_in_development(): # This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
config_file = './tests/ceo_dev.ini' config_file = './tests/ceo_dev.ini'
else: else:
config_file = '/etc/csc/ceo.ini' config_file = '/etc/csc/ceo.ini'

View File

@ -28,11 +28,6 @@ def activate():
'Congratulations! Your cloud account has been activated.', 'Congratulations! Your cloud account has been activated.',
f'You may now login into https://cloud.{base_domain} with your CSC credentials.', f'You may now login into https://cloud.{base_domain} with your CSC credentials.',
"Make sure to enter 'Members' for the domain (no quotes).", "Make sure to enter 'Members' for the domain (no quotes).",
'',
'Please note that your cloud account will be PERMANENTLY DELETED when',
'your CSC membership expires, so make sure to purchase enough membership',
'terms in advance. You will receive a warning email one week before your',
'cloud account is deleted, so please make sure to check your Junk folder.',
] ]
for line in lines: for line in lines:
click.echo(line) click.echo(line)

View File

@ -149,15 +149,3 @@ def delete(group_name):
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True) click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
resp = http_delete(f'/api/groups/{group_name}') resp = http_delete(f'/api/groups/{group_name}')
handle_stream_response(resp, DeleteGroupTransaction.operations) handle_stream_response(resp, DeleteGroupTransaction.operations)
@groups.command(short_help='Search for groups')
@click.argument('query')
@click.option('--count', default=10, help='number of results to show')
def search(query, count):
check_if_in_development()
resp = http_get(f'/api/groups/search/{query}/{count}')
result = handle_sync_response(resp)
for cn in result:
if cn != "":
click.echo(cn)

View File

@ -3,16 +3,13 @@ from typing import Dict
import click import click
from zope import component from zope import component
from ceo_common.utils import validate_username
from ..term_utils import get_terms_for_new_user, get_terms_for_renewal
from ..term_utils import get_terms_for_renewal_for_user
from ..utils import http_post, http_get, http_patch, http_delete, \ from ..utils import http_post, http_get, http_patch, http_delete, \
get_failed_operations, user_dict_lines, get_adduser_operations get_failed_operations, user_dict_lines, get_adduser_operations
from .utils import handle_stream_response, handle_sync_response, print_lines, \ from .utils import handle_stream_response, handle_sync_response, print_lines, \
check_if_in_development check_if_in_development
from ceo_common.interfaces import IConfig from ceo_common.interfaces import IConfig
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import DeleteMemberTransaction from ceod.transactions.members import DeleteMemberTransaction
@ -39,11 +36,6 @@ def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_ad
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
uw_domain = cfg.get('uw_domain') uw_domain = cfg.get('uw_domain')
# Verify that the username is valid before requesting data from UWLDAP
username_validator = validate_username(username)
if not username_validator.is_valid:
return click.echo("The provided username is invalid")
# Try to get info from UWLDAP # Try to get info from UWLDAP
resp = http_get('/api/uwldap/' + username) resp = http_get('/api/uwldap/' + username)
if resp.ok: if resp.ok:
@ -56,7 +48,7 @@ def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_ad
sn = result['sn'] sn = result['sn']
if program is None and result.get('program'): if program is None and result.get('program'):
program = result['program'] program = result['program']
if forwarding_address is None and result.get('mail_local_addresses'): if forwarding_address is None:
forwarding_address = result['mail_local_addresses'][0] forwarding_address = result['mail_local_addresses'][0]
if cn is None: if cn is None:
cn = click.prompt('Full name') cn = click.prompt('Full name')
@ -163,7 +155,7 @@ def modify(username, login_shell, forwarding_addresses):
@click.option('--clubrep', is_flag=True, default=False, @click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms') help='Add non-member terms instead of member terms')
def renew(username, num_terms, clubrep): def renew(username, num_terms, clubrep):
terms = get_terms_for_renewal_for_user(username, num_terms, clubrep) terms = get_terms_for_renewal(username, num_terms, clubrep)
if clubrep: if clubrep:
body = {'non_member_terms': terms} body = {'non_member_terms': terms}

View File

@ -16,22 +16,13 @@ def positions():
def get(): def get():
resp = http_get('/api/positions') resp = http_get('/api/positions')
result = handle_sync_response(resp) result = handle_sync_response(resp)
print_colon_kv([ print_colon_kv(result.items())
(position, ', '.join(usernames))
for position, usernames in result.items()
])
@positions.command(short_help='Update positions') @positions.command(short_help='Update positions')
def set(**kwargs): def set(**kwargs):
body = { body = {k.replace('_', '-'): v for k, v in kwargs.items()}
k.replace('_', '-'): v.replace(' ', '').split(',') if v else None print_body = {k: v or '' for k, v in body.items()}
for k, v in kwargs.items()
}
print_body = {
k: ', '.join(v) if v else ''
for k, v in body.items()
}
click.echo('The positions will be updated:') click.echo('The positions will be updated:')
print_colon_kv(print_body.items()) print_colon_kv(print_body.items())
click.confirm('Do you want to continue?', abort=True) click.confirm('Do you want to continue?', abort=True)

View File

@ -1,9 +1,10 @@
import socket
from typing import List, Tuple, Dict from typing import List, Tuple, Dict
import click import click
import requests import requests
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler from .CLIStreamResponseHandler import CLIStreamResponseHandler
@ -52,6 +53,6 @@ def handle_sync_response(resp: requests.Response):
def check_if_in_development() -> bool: def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment.""" """Aborts if we are not currently in the dev environment."""
if not is_in_development(): if not socket.getfqdn().endswith('.csclub.internal'):
click.echo('This command may only be called during development.') click.echo('This command may only be called during development.')
raise Abort() raise Abort()

View File

@ -1,22 +1,38 @@
from typing import List from typing import List
from .utils import http_get from .utils import http_get
from ceo_common.model.Term import get_terms_for_renewal from ceo_common.model import Term
import ceo.cli.utils as cli_utils import ceo.cli.utils as cli_utils
import ceo.tui.utils as tui_utils import ceo.tui.utils as tui_utils
# Had to put these in a separate file to avoid a circular import.
def get_terms_for_renewal_for_user(
def get_terms_for_new_user(num_terms: int) -> List[str]:
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
return list(map(str, terms))
def get_terms_for_renewal(
username: str, num_terms: int, clubrep: bool, tui_controller=None, username: str, num_terms: int, clubrep: bool, tui_controller=None,
) -> List[str]: ) -> List[str]:
resp = http_get('/api/members/' + username) resp = http_get('/api/members/' + username)
# FIXME: this is ugly, we shouldn't need a hacky if statement like this
if tui_controller is None: if tui_controller is None:
result = cli_utils.handle_sync_response(resp) result = cli_utils.handle_sync_response(resp)
else: else:
result = tui_utils.handle_sync_response(resp, tui_controller) result = tui_utils.handle_sync_response(resp, tui_controller)
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 clubrep: if max_term is not None and max_term >= current_term:
return get_terms_for_renewal(result.get('non_member_terms'), num_terms) next_term = max_term + 1
else: else:
return get_terms_for_renewal(result.get('terms'), num_terms) next_term = Term.current()
terms = [next_term + i for i in range(num_terms)]
return list(map(str, terms))

View File

@ -3,9 +3,9 @@ from threading import Thread
from ...utils import http_get from ...utils import http_get
from .Controller import Controller from .Controller import Controller
from .AddUserTransactionController import AddUserTransactionController from .AddUserTransactionController import AddUserTransactionController
import ceo.term_utils as term_utils
from ceo.tui.models import TransactionModel from ceo.tui.models import TransactionModel
from ceo.tui.views import AddUserConfirmationView, TransactionView from ceo.tui.views import AddUserConfirmationView, TransactionView
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import AddMemberTransaction from ceod.transactions.members import AddMemberTransaction
@ -26,7 +26,7 @@ class AddUserController(Controller):
body['program'] = self.model.program body['program'] = self.model.program
if self.model.forwarding_address: if self.model.forwarding_address:
body['forwarding_addresses'] = [self.model.forwarding_address] body['forwarding_addresses'] = [self.model.forwarding_address]
new_terms = get_terms_for_new_user(self.model.num_terms) new_terms = term_utils.get_terms_for_new_user(self.model.num_terms)
if self.model.membership_type == 'club_rep': if self.model.membership_type == 'club_rep':
body['non_member_terms'] = new_terms body['non_member_terms'] = new_terms
else: else:
@ -106,5 +106,5 @@ class AddUserController(Controller):
self.model.first_name = data.get('given_name', '') self.model.first_name = data.get('given_name', '')
self.model.last_name = data.get('sn', '') self.model.last_name = data.get('sn', '')
self.model.program = data.get('program', '') self.model.program = data.get('program', '')
self.model.forwarding_address = (data.get('mail_local_addresses') or [''])[0] self.model.forwarding_address = data.get('mail_local_addresses', [''])[0]
self.app.run_in_main_loop(self._on_lookup_user_success) self.app.run_in_main_loop(self._on_lookup_user_success)

View File

@ -1,7 +1,6 @@
from abc import ABC from abc import ABC
import ceo.tui.utils as utils import ceo.tui.utils as utils
from ceo_common.utils import validate_username
# NOTE: one controller can control multiple views, # NOTE: one controller can control multiple views,
@ -53,9 +52,8 @@ class Controller(ABC):
def get_username_from_view(self): def get_username_from_view(self):
username = self.view.username_edit.edit_text username = self.view.username_edit.edit_text
# TODO: share validation logic between CLI and TUI # TODO: share validation logic between CLI and TUI
verification_res = validate_username(username) if not username:
if not verification_res.is_valid: self.view.popup('Username must not be empty')
self.view.popup(verification_res.error_message)
raise Controller.InvalidInput() raise Controller.InvalidInput()
return username return username

View File

@ -19,8 +19,8 @@ class GetPositionsController(Controller):
positions = tui_utils.handle_sync_response(resp, self) positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed: except Controller.RequestFailed:
return return
for pos, usernames in positions.items(): for pos, username in positions.items():
self.model.positions[pos] = ','.join(usernames) self.model.positions[pos] = username
def target(): def target():
self.view.flash_text.set_text('') self.view.flash_text.set_text('')

View File

@ -28,7 +28,7 @@ class RenewUserController(SyncRequestController):
def _get_next_terms(self): def _get_next_terms(self):
try: try:
self.model.new_terms = term_utils.get_terms_for_renewal_for_user( self.model.new_terms = term_utils.get_terms_for_renewal(
self.model.username, self.model.username,
self.model.num_terms, self.model.num_terms,
self.model.membership_type == 'club_rep', self.model.membership_type == 'club_rep',

View File

@ -1,40 +0,0 @@
from ceo.utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import SearchGroupResponseView, GetGroupResponseView
# this is a little bit bad because it relies on zero coupling between
# the GetGroupResponseView and the GetGroupController
# coupling is also introduced between this controller and the
# SearchGroupResponseView as it requires this class's callback
class SearchGroupController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
if self.model.want_info:
return http_get(f'/api/groups/{self.model.name}')
else:
return http_get(f'/api/groups/search/{self.model.name}/{self.model.count}')
def get_response_view(self):
if self.model.want_info:
return GetGroupResponseView(self.model, self, self.app)
else:
return SearchGroupResponseView(self.model, self, self.app)
def group_info_callback(self, button, cn):
self.model.name = cn
self.model.want_info = True
self.request_in_progress = False
self.on_next_button_pressed(button)
def on_next_button_pressed(self, button):
try:
if not self.model.want_info:
self.model.name = self.get_username_from_view()
self.model.count = 10
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -17,7 +17,7 @@ class SetPositionsController(Controller):
body = {} body = {}
for pos, field in self.view.position_fields.items(): for pos, field in self.view.position_fields.items():
if field.edit_text != '': if field.edit_text != '':
body[pos] = field.edit_text.replace(' ', '').split(',') body[pos] = field.edit_text
model = TransactionModel( model = TransactionModel(
UpdateMemberPositionsTransaction.operations, UpdateMemberPositionsTransaction.operations,
'POST', '/api/positions', json=body 'POST', '/api/positions', json=body
@ -37,8 +37,8 @@ class SetPositionsController(Controller):
positions = tui_utils.handle_sync_response(resp, self) positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed: except Controller.RequestFailed:
return return
for pos, usernames in positions.items(): for pos, username in positions.items():
self.model.positions[pos] = ','.join(usernames) self.model.positions[pos] = username
def target(): def target():
self.view.flash_text.set_text('') self.view.flash_text.set_text('')

View File

@ -8,7 +8,6 @@ from .ResetPasswordController import ResetPasswordController
from .ChangeLoginShellController import ChangeLoginShellController from .ChangeLoginShellController import ChangeLoginShellController
from .AddGroupController import AddGroupController from .AddGroupController import AddGroupController
from .GetGroupController import GetGroupController from .GetGroupController import GetGroupController
from .SearchGroupController import SearchGroupController
from .AddMemberToGroupController import AddMemberToGroupController from .AddMemberToGroupController import AddMemberToGroupController
from .RemoveMemberFromGroupController import RemoveMemberFromGroupController from .RemoveMemberFromGroupController import RemoveMemberFromGroupController
from .CreateDatabaseController import CreateDatabaseController from .CreateDatabaseController import CreateDatabaseController

View File

@ -1,9 +0,0 @@
class SearchGroupModel:
name = 'SearchGroup'
title = 'Search groups'
def __init__(self):
self.name = ''
self.resp_json = None
self.count = 10
self.want_info = False

View File

@ -5,7 +5,6 @@ from .ResetPasswordModel import ResetPasswordModel
from .ChangeLoginShellModel import ChangeLoginShellModel from .ChangeLoginShellModel import ChangeLoginShellModel
from .AddGroupModel import AddGroupModel from .AddGroupModel import AddGroupModel
from .GetGroupModel import GetGroupModel from .GetGroupModel import GetGroupModel
from .SearchGroupModel import SearchGroupModel
from .AddMemberToGroupModel import AddMemberToGroupModel from .AddMemberToGroupModel import AddMemberToGroupModel
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
from .CreateDatabaseModel import CreateDatabaseModel from .CreateDatabaseModel import CreateDatabaseModel
@ -30,7 +29,6 @@ class WelcomeModel:
'Groups': [ 'Groups': [
AddGroupModel, AddGroupModel,
GetGroupModel, GetGroupModel,
SearchGroupModel,
AddMemberToGroupModel, AddMemberToGroupModel,
RemoveMemberFromGroupModel, RemoveMemberFromGroupModel,
], ],

View File

@ -6,7 +6,6 @@ from .ResetPasswordModel import ResetPasswordModel
from .ChangeLoginShellModel import ChangeLoginShellModel from .ChangeLoginShellModel import ChangeLoginShellModel
from .AddGroupModel import AddGroupModel from .AddGroupModel import AddGroupModel
from .GetGroupModel import GetGroupModel from .GetGroupModel import GetGroupModel
from .SearchGroupModel import SearchGroupModel
from .AddMemberToGroupModel import AddMemberToGroupModel from .AddMemberToGroupModel import AddMemberToGroupModel
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
from .CreateDatabaseModel import CreateDatabaseModel from .CreateDatabaseModel import CreateDatabaseModel

View File

@ -25,7 +25,6 @@ def handle_sync_response(resp, controller):
raise Controller.RequestFailed() raise Controller.RequestFailed()
# this can probably be simplified with getattr or something
def get_mvc(app, name): def get_mvc(app, name):
if name == WelcomeModel.name: if name == WelcomeModel.name:
model = WelcomeModel() model = WelcomeModel()
@ -59,10 +58,6 @@ def get_mvc(app, name):
model = GetGroupModel() model = GetGroupModel()
controller = GetGroupController(model, app) controller = GetGroupController(model, app)
view = GetGroupView(model, controller, app) view = GetGroupView(model, controller, app)
elif name == SearchGroupModel.name:
model = SearchGroupModel()
controller = SearchGroupController(model, app)
view = SearchGroupView(model, controller, app)
elif name == AddMemberToGroupModel.name: elif name == AddMemberToGroupModel.name:
model = AddMemberToGroupModel() model = AddMemberToGroupModel()
controller = AddMemberToGroupController(model, app) controller = AddMemberToGroupController(model, app)

View File

@ -53,10 +53,6 @@ class AddUserView(ColumnView):
urwid.Text('Program:', align='right'), urwid.Text('Program:', align='right'),
self.program_edit self.program_edit
), ),
(
urwid.Text('Forwarding address:', align='right'),
self.forwarding_address_edit
),
( (
urwid.Text('Number of terms:', align='right'), urwid.Text('Number of terms:', align='right'),
self.num_terms_edit self.num_terms_edit

View File

@ -1,21 +0,0 @@
import urwid
from .ColumnResponseView import ColumnResponseView
from .utils import decorate_button
class SearchGroupResponseView(ColumnResponseView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
matches = self.model.resp_json.copy()
rows = [(urwid.Text(resp),
decorate_button(urwid.Button('more info', on_press=self.create_callback(resp))))
for resp in matches if resp != '']
self.set_rows(rows, on_next=self.controller.get_next_menu_callback('Welcome'))
def create_callback(self, cn):
def callback(button):
self.controller.group_info_callback(button, cn)
return callback

View File

@ -1,17 +0,0 @@
import urwid
from .ColumnView import ColumnView
class SearchGroupView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
# used for query, consider combining Controller.user_name_from_view and Controller.get_group_name_from_view
self.username_edit = urwid.Edit()
rows = [
(
urwid.Text('Query:', align='right'),
self.username_edit
)
]
self.set_rows(rows)

View File

@ -16,8 +16,6 @@ from .AddGroupView import AddGroupView
from .AddGroupConfirmationView import AddGroupConfirmationView from .AddGroupConfirmationView import AddGroupConfirmationView
from .GetGroupView import GetGroupView from .GetGroupView import GetGroupView
from .GetGroupResponseView import GetGroupResponseView from .GetGroupResponseView import GetGroupResponseView
from .SearchGroupView import SearchGroupView
from .SearchGroupResponseView import SearchGroupResponseView
from .AddMemberToGroupView import AddMemberToGroupView from .AddMemberToGroupView import AddMemberToGroupView
from .AddMemberToGroupConfirmationView import AddMemberToGroupConfirmationView from .AddMemberToGroupConfirmationView import AddMemberToGroupConfirmationView
from .RemoveMemberFromGroupView import RemoveMemberFromGroupView from .RemoveMemberFromGroupView import RemoveMemberFromGroupView

View File

@ -5,15 +5,8 @@ position_names = {
'secretary': "Secretary", 'secretary': "Secretary",
'sysadmin': "Sysadmin", 'sysadmin': "Sysadmin",
'cro': "Chief Returning Officer", 'cro': "Chief Returning Officer",
'librarian': "Librarian",
'imapd': "IMAPD",
'webmaster': "Web Master", 'webmaster': "Web Master",
'offsck': "Office Manager", 'offsck': "Office Manager",
'events-lead': "Events Lead",
'ext-affairs-lead': "External Affairs Lead",
'marketing-lead': "Marketing Lead",
'design-lead': "Design Lead",
'reps-lead': "Reps Lead",
'mods-lead': "Mods Lead",
'photography-lead': "Photography Lead",
'codey-bot-lead': 'Codey Bot Lead',
'other': 'Other',
} }

View File

@ -87,9 +87,6 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
maxlen = max(len(key) for key, val in pairs) maxlen = max(len(key) for key, val in pairs)
for key, val in pairs: for key, val in pairs:
if key != '': if key != '':
if not val:
lines.append(key + ':')
continue
prefix = key + ': ' prefix = key + ': '
else: else:
# assume this is a continuation from the previous line # assume this is a continuation from the previous line
@ -139,8 +136,6 @@ def user_dict_kv(d: Dict) -> List[Tuple[str]]:
pairs.append(('non-member terms', ','.join(_terms))) pairs.append(('non-member terms', ','.join(_terms)))
if 'password' in d: if 'password' in d:
pairs.append(('password', d['password'])) pairs.append(('password', d['password']))
if 'groups' in d:
pairs.append(('groups', ','.join(d['groups'])))
return pairs return pairs

View File

@ -1,18 +0,0 @@
import typing
from typing import Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.ADLDAPRecord import ADLDAPRecord
class IADLDAPService(Interface):
"""Represents the AD LDAP database."""
def get_user(username: str) -> Optional['ADLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
"""

View File

@ -19,7 +19,8 @@ class ICloudStackService(Interface):
The dict is mapping of usernames to account IDs. The dict is mapping of usernames to account IDs.
""" """
def delete_account(username: str, account_id: str): def delete_account(account_id: str):
""" """
Delete the given CloudStack account. Delete the given CloudStack account.
Note that a CloudStack account ID must be given, not a username.
""" """

View File

@ -33,11 +33,6 @@ class ILDAPService(Interface):
A new UID and GID will be generated and returned in the new user. A new UID and GID will be generated and returned in the new user.
""" """
def get_groups_for_user(username: str) -> List[str]:
"""
Get a list of the groups to which the user belongs.
"""
def remove_user(user: IUser): def remove_user(user: IUser):
"""Remove this user from the database.""" """Remove this user from the database."""

View File

@ -1,23 +1,20 @@
import typing from typing import List, Union
from typing import List, Optional
from zope.interface import Interface from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.UWLDAPRecord import UWLDAPRecord
class IUWLDAPService(Interface): class IUWLDAPService(Interface):
"""Represents the UW LDAP database.""" """Represents the UW LDAP database."""
def get_user(username: str) -> Optional['UWLDAPRecord']: def get_user(username: str):
""" """
Return the LDAP record for the given user, or Return the LDAP record for the given user, or
None if no such record exists. None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
""" """
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]: def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
""" """
Return the programs for the given users from UWLDAP. Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in If no record or program is found for a user, their entry in

View File

@ -1,4 +1,3 @@
from .IADLDAPService import IADLDAPService
from .ICloudResourceManager import ICloudResourceManager from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService from .IKerberosService import IKerberosService

View File

@ -1,57 +0,0 @@
import ldap3
from .UWLDAPRecord import UWLDAPRecord
class ADLDAPRecord:
"""Represents a record from the AD LDAP."""
# These are just the ones in which we're interested
ldap_attributes = [
'sAMAccountName',
'mail',
'description',
'sn',
'givenName',
]
def __init__(
self,
sam_account_name: str,
mail: str,
description: str,
sn: str,
given_name: str,
):
self.sam_account_name = sam_account_name
self.mail = mail
self.description = description
self.sn = sn
self.given_name = given_name
@staticmethod
def deserialize_from_ldap(entry: ldap3.Entry):
"""
Deserializes a dict returned from LDAP into an
ADLDAPRecord.
"""
return ADLDAPRecord(
sam_account_name=entry.sAMAccountName.value,
mail=entry.mail.value,
description=entry.description.value,
sn=entry.sn.value,
given_name=entry.givenName.value,
)
def to_uwldap_record(self) -> UWLDAPRecord:
"""Converts this AD LDAP record into a UW LDAP record."""
return UWLDAPRecord(
uid=self.sam_account_name,
mail_local_addresses=[self.mail],
program=None,
# The description attribute has the format "Lastname, Firstname"
cn=' '.join(self.description.split(', ')[::-1]),
# Alumni's last names are prepended with an asterisk
sn=(self.sn[1:] if self.sn[0] == '*' else self.sn),
given_name=self.given_name
)

View File

@ -6,7 +6,7 @@ from requests_gssapi import HTTPSPNEGOAuth
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from ceo_common.interfaces import IConfig, IHTTPClient, IKerberosService from ceo_common.interfaces import IConfig, IHTTPClient
@implementer(IHTTPClient) @implementer(IHTTPClient)
@ -40,18 +40,10 @@ class HTTPClient:
'opportunistic_auth': True, 'opportunistic_auth': True,
'target_name': gssapi.Name('ceod/' + host), 'target_name': gssapi.Name('ceod/' + host),
} }
if flask.has_request_context(): if flask.has_request_context() and 'client_token' in g:
# This is reached when we are the server and the client has # This is reached when we are the server and the client has
# forwarded their credentials to us. # forwarded their credentials to us.
token = None spnego_kwargs['creds'] = gssapi.Credentials(token=g.client_token)
if g.get('need_admin_creds', False):
# Some Kerberos bindings in some programming languages can't
# perform delegation, so use the admin creds here.
token = component.getUtility(IKerberosService).get_admin_creds_token()
elif 'client_token' in g:
token = g.client_token
if token is not None:
spnego_kwargs['creds'] = gssapi.Credentials(token=token)
elif delegate: elif delegate:
# This is reached when we are the client and we want to # This is reached when we are the client and we want to
# forward our credentials to the server. # forward our credentials to the server.

View File

@ -1,5 +1,4 @@
import datetime import datetime
from typing import List, Union
import ceo_common.utils as utils import ceo_common.utils as utils
@ -82,39 +81,3 @@ class Term:
month = self.seasons.index(c) * 4 + 1 month = self.seasons.index(c) * 4 + 1
day = 1 day = 1
return datetime.datetime(year, month, day) return datetime.datetime(year, month, day)
# Utility functions
def get_terms_for_new_user(num_terms: int) -> List[str]:
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
return list(map(str, terms))
def get_terms_for_renewal(
existing_terms: Union[List[str], None],
num_terms: int,
) -> List[str]:
"""Calculates the terms for which a member or club rep should be renewed.
:param terms: The existing terms for the user being renewed. If the user
is being renewed as a regular member, these should be the
member terms. If they are being renewed as a club rep, these
should be the non-member terms.
This may be None if the user does not have any terms of the
appropriate type (an empty list is also acceptable).
:param num_terms: The number of terms for which the user is being renewed.
"""
max_term = None
current_term = Term.current()
if existing_terms:
max_term = max(map(Term, existing_terms))
if max_term is not None and max_term >= current_term:
next_term = max_term + 1
else:
next_term = current_term
terms = [next_term + i for i in range(num_terms)]
return list(map(str, terms))

View File

@ -1,6 +1,4 @@
from .ADLDAPRecord import ADLDAPRecord
from .Config import Config from .Config import Config
from .HTTPClient import HTTPClient from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService from .RemoteMailmanService import RemoteMailmanService
from .Term import Term from .Term import Term
from .UWLDAPRecord import UWLDAPRecord

View File

@ -1,78 +1,7 @@
import datetime import datetime
import re
from dataclasses import dataclass
import socket
# TODO: disallow underscores. Will break many tests with usernames that include _
VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$")
class fuzzy_result:
def __init__(self, string, score):
self.string = string
self.score = score
# consider a score worse if the edit distance is larger
def __lt__(self, other):
return self.score > other.score
def __gt__(self, other):
return self.score < other.score
def __le__(self, other):
return self.score >= other.score
def __ge__(self, other):
return self.score <= other.score
def __eq__(self, other):
return self.score == other.score
def __ne__(self, other):
return self.score != other.score
# compute levenshtein edit distance, adapted from rosetta code
def fuzzy_match(s1, s2):
if len(s1) == 0:
return len(s2)
if len(s2) == 0:
return len(s1)
edits = [i for i in range(len(s2) + 1)]
for i in range(len(s1)):
corner = i
edits[0] = i + 1
for j in range(len(s2)):
upper = edits[j + 1]
if s1[i] == s2[j]:
edits[j + 1] = corner
else:
m = min(corner, upper, edits[j])
edits[j + 1] = m + 1
corner = upper
return edits[-1]
def get_current_datetime() -> datetime.datetime: def get_current_datetime() -> datetime.datetime:
# We place this in a separate function so that we can mock it out # We place this in a separate function so that we can mock it out
# in our unit tests. # in our unit tests.
return datetime.datetime.now() return datetime.datetime.now()
@dataclass
class UsernameValidationResult:
is_valid: bool
error_message: str = ''
def validate_username(username: str) -> UsernameValidationResult:
if not username:
return UsernameValidationResult(False, 'Username must not be empty')
if not VALID_USERNAME_RE.fullmatch(username):
return UsernameValidationResult(False, 'Username is invalid')
return UsernameValidationResult(True)
def is_in_development() -> bool:
"""This is a hack to determine if we're in the dev env or not"""
return socket.getfqdn().endswith('.csclub.internal')

View File

@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \ from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \ IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \ ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
IContainerRegistryService, IClubWebHostingService, IADLDAPService IContainerRegistryService, IClubWebHostingService
from ceo_common.model import Config, HTTPClient, RemoteMailmanService from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \ from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService, CloudStackService, \ MailmanService, MailService, UWLDAPService, CloudStackService, \
CloudResourceManager, KubernetesService, VHostManager, \ CloudResourceManager, KubernetesService, VHostManager, \
ContainerRegistryService, ClubWebHostingService, ADLDAPService ContainerRegistryService, ClubWebHostingService
from ceod.db import MySQLService, PostgreSQLService from ceod.db import MySQLService, PostgreSQLService
@ -36,15 +36,6 @@ def create_app(flask_config={}):
from ceod.api import members from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members') app.register_blueprint(members.bp, url_prefix='/api/members')
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')
# Only offer mailman API if this host is running Mailman # Only offer mailman API if this host is running Mailman
if hostname == cfg.get('ceod_mailman_host'): if hostname == cfg.get('ceod_mailman_host'):
from ceod.api import mailman from ceod.api import mailman
@ -62,6 +53,15 @@ def create_app(flask_config={}):
from ceod.api import webhosting from ceod.api import webhosting
app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting') app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting')
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) register_error_handlers(app)
@app.route('/ping') @app.route('/ping')
@ -74,7 +74,7 @@ def create_app(flask_config={}):
def register_services(app): def register_services(app):
# Config # Config
if app.config.get('DEBUG') and 'CEOD_CONFIG' not in os.environ: if app.config.get('ENV') == 'development' and 'CEOD_CONFIG' not in os.environ:
with importlib.resources.path('tests', 'ceod_dev.ini') as p: with importlib.resources.path('tests', 'ceod_dev.ini') as p:
config_file = p.__fspath__() config_file = p.__fspath__()
else: else:
@ -112,13 +112,9 @@ def register_services(app):
mail_srv = MailService() mail_srv = MailService()
component.provideUtility(mail_srv, IMailService) component.provideUtility(mail_srv, IMailService)
# UWLDAPService, ADLDAPService # UWLDAPService
if hostname == cfg.get('ceod_admin_host'): uwldap_srv = UWLDAPService()
uwldap_srv = UWLDAPService() component.provideUtility(uwldap_srv, IUWLDAPService)
component.provideUtility(uwldap_srv, IUWLDAPService)
adldap_srv = ADLDAPService()
component.provideUtility(adldap_srv, IADLDAPService)
# ClubWebHostingService # ClubWebHostingService
if hostname == cfg.get('ceod_webhosting_host'): if hostname == cfg.get('ceod_webhosting_host'):

View File

@ -1,12 +1,9 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask.json import jsonify
from zope import component from zope import component
from .utils import authz_restrict_to_syscom, is_truthy, \ from .utils import authz_restrict_to_syscom, is_truthy, \
create_streaming_response, development_only, requires_admin_creds, \ create_streaming_response, development_only
requires_authentication_no_realm, user_is_in_group
from ceo_common.interfaces import ILDAPService from ceo_common.interfaces import ILDAPService
from ceo_common.utils import fuzzy_result, fuzzy_match
from ceod.transactions.groups import ( from ceod.transactions.groups import (
AddGroupTransaction, AddGroupTransaction,
AddMemberToGroupTransaction, AddMemberToGroupTransaction,
@ -14,8 +11,6 @@ from ceod.transactions.groups import (
DeleteGroupTransaction, DeleteGroupTransaction,
) )
from heapq import heappushpop, nlargest
bp = Blueprint('groups', __name__) bp = Blueprint('groups', __name__)
@ -37,42 +32,9 @@ def get_group(group_name):
return group.to_dict() return group.to_dict()
@bp.route('/search/<query>/<count>')
def search_group(query, count):
query = str(query)
count = int(count)
ldap_srv = component.getUtility(ILDAPService)
clubs = ldap_srv.get_clubs()
scores = [fuzzy_result("", 99999) for _ in range(count)]
for club in clubs:
score = fuzzy_match(query, str(club.cn))
result = fuzzy_result(str(club.cn), score)
heappushpop(scores, result)
result = [score.string for score in nlargest(count, scores)]
return jsonify(result)
def may_add_user_to_group(auth_username: str, group_name: str) -> bool:
# (is syscom) OR (group is office AND client is offsck)
if user_is_in_group(auth_username, 'syscom'):
return True
if group_name == 'office':
ldap_srv = component.getUtility(ILDAPService)
auth_user = ldap_srv.get_user(auth_username)
if 'offsck' in auth_user.positions:
return True
return False
@bp.route('/<group_name>/members/<username>', methods=['POST']) @bp.route('/<group_name>/members/<username>', methods=['POST'])
@requires_admin_creds @authz_restrict_to_syscom
@requires_authentication_no_realm def add_member_to_group(group_name, username):
def add_member_to_group(auth_username: str, group_name: str, username: str):
# Admin creds are required because slapd does not support access control
# rules which use the client's attributes
if not may_add_user_to_group(auth_username, group_name):
return {'error': "not authorized to add user to group"}, 403
subscribe_to_lists = is_truthy( subscribe_to_lists = is_truthy(
request.args.get('subscribe_to_lists', 'true') request.args.get('subscribe_to_lists', 'true')
) )
@ -85,13 +47,8 @@ def add_member_to_group(auth_username: str, group_name: str, username: str):
@bp.route('/<group_name>/members/<username>', methods=['DELETE']) @bp.route('/<group_name>/members/<username>', methods=['DELETE'])
@requires_admin_creds @authz_restrict_to_syscom
@requires_authentication_no_realm def remove_member_from_group(group_name, username):
def remove_member_from_group(auth_username: str, group_name: str, username: str):
# Admin creds are required because slapd does not support access control
# rules which use the client's attributes
if not may_add_user_to_group(auth_username, group_name):
return {'error': "not authorized to add user to group"}, 403
unsubscribe_from_lists = is_truthy( unsubscribe_from_lists = is_truthy(
request.args.get('unsubscribe_from_lists', 'true') request.args.get('unsubscribe_from_lists', 'true')
) )

View File

@ -1,15 +1,13 @@
from flask import Blueprint, request from flask import Blueprint, g, request
from flask.json import jsonify from flask.json import jsonify
from zope import component from zope import component
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
user_is_in_group, requires_authentication_no_realm, requires_admin_creds, \ user_is_in_group, requires_authentication_no_realm, \
create_streaming_response, development_only, is_truthy create_streaming_response, development_only, is_truthy
from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError
from ceo_common.interfaces import ILDAPService, IConfig, IMailService from ceo_common.interfaces import ILDAPService, IConfig, IMailService
from ceo_common.logger_factory import logger_factory from ceo_common.logger_factory import logger_factory
from ceo_common.model.Term import get_terms_for_new_user, get_terms_for_renewal
from ceo_common.utils import validate_username
from ceod.transactions.members import ( from ceod.transactions.members import (
AddMemberTransaction, AddMemberTransaction,
ModifyMemberTransaction, ModifyMemberTransaction,
@ -22,36 +20,20 @@ logger = logger_factory(__name__)
@bp.route('/', methods=['POST'], strict_slashes=False) @bp.route('/', methods=['POST'], strict_slashes=False)
@requires_admin_creds
@authz_restrict_to_staff @authz_restrict_to_staff
def create_user(): def create_user():
# We need to use the admin creds here because office members may not
# directly create new LDAP records.
body = request.get_json(force=True) body = request.get_json(force=True)
terms = body.get('terms') terms = body.get('terms')
non_member_terms = body.get('non_member_terms') non_member_terms = body.get('non_member_terms')
if (terms and non_member_terms) or not (terms or non_member_terms): if (terms and non_member_terms) or not (terms or non_member_terms):
raise BadRequest('Must specify either terms or non-member terms') raise BadRequest('Must specify either terms or non-member terms')
if type(terms) is int: for attr in ['uid', 'cn', 'given_name', 'sn']:
terms = get_terms_for_new_user(terms)
elif type(non_member_terms) is int:
non_member_terms = get_terms_for_new_user(non_member_terms)
for attr in ['uid', 'cn', 'given_name', 'sn', 'forwarding_addresses']:
if not body.get(attr): if not body.get(attr):
raise BadRequest(f"Attribute '{attr}' is missing or empty") raise BadRequest(f"Attribute '{attr}' is missing or empty")
if type(body['forwarding_addresses']) is not list:
raise BadRequest('forwarding_addresses must be a list of email addresses')
uid_validator = validate_username(body['uid']) # We need to use the admin creds here because office members may not
if not uid_validator.is_valid: # directly create new LDAP records.
raise BadRequest("Attribute 'uid' is missing or invalid") g.need_admin_creds = True
if terms:
logger.info(f"Creating member {body['uid']} for terms {terms}")
else:
logger.info(f"Creating club rep {body['uid']} for non-member terms {non_member_terms}")
txn = AddMemberTransaction( txn = AddMemberTransaction(
uid=body['uid'], uid=body['uid'],
@ -61,7 +43,7 @@ def create_user():
program=body.get('program'), program=body.get('program'),
terms=terms, terms=terms,
non_member_terms=non_member_terms, non_member_terms=non_member_terms,
forwarding_addresses=body['forwarding_addresses'], forwarding_addresses=body.get('forwarding_addresses'),
) )
return create_streaming_response(txn) return create_streaming_response(txn)
@ -79,9 +61,7 @@ def get_user(auth_user: str, username: str):
get_forwarding_addresses = True get_forwarding_addresses = True
ldap_srv = component.getUtility(ILDAPService) ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username) user = ldap_srv.get_user(username)
user_dict = user.to_dict(get_forwarding_addresses) return user.to_dict(get_forwarding_addresses)
user_dict['groups'] = ldap_srv.get_groups_for_user(username)
return user_dict
@bp.route('/<username>', methods=['PATCH']) @bp.route('/<username>', methods=['PATCH'])
@ -101,48 +81,40 @@ def patch_user(auth_user: str, username: str):
@bp.route('/<username>/renew', methods=['POST']) @bp.route('/<username>/renew', methods=['POST'])
@requires_admin_creds
@authz_restrict_to_staff @authz_restrict_to_staff
def renew_user(username: str): def renew_user(username: str):
# We need to use the admin creds here because office members should
# not be able to directly modify the shadowExpire field; this could
# prevent syscom members from logging into the machines.
body = request.get_json(force=True) body = request.get_json(force=True)
terms = body.get('terms') terms = body.get('terms')
non_member_terms = body.get('non_member_terms') non_member_terms = body.get('non_member_terms')
if (terms and non_member_terms) or not (terms or non_member_terms): if (terms and non_member_terms) or not (terms or non_member_terms):
raise BadRequest('Must specify either terms or non-member terms') raise BadRequest('Must specify either terms or non-member terms')
# We need to use the admin creds here because office members should
# not be able to directly modify the shadowExpire field; this could
# prevent syscom members from logging into the machines.
g.need_admin_creds = True
ldap_srv = component.getUtility(ILDAPService) ldap_srv = component.getUtility(ILDAPService)
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
user = ldap_srv.get_user(username) user = ldap_srv.get_user(username)
member_list = cfg.get('mailman3_new_member_list') member_list = cfg.get('mailman3_new_member_list')
if type(terms) is int:
terms = get_terms_for_renewal(user.terms, terms)
elif type(non_member_terms) is int:
non_member_terms = get_terms_for_renewal(user.non_member_terms, non_member_terms)
def unexpire(user): def unexpire(user):
if user.shadowExpire: if user.shadowExpire:
user.set_expired(False) user.set_expired(False)
try: try:
user.subscribe_to_mailing_list(member_list) user.subscribe_to_mailing_list(member_list)
logger.debug(f'Subscribed {user.uid} to {member_list}')
except UserAlreadySubscribedError: except UserAlreadySubscribedError:
logger.debug(f'{user.uid} is already subscribed to {member_list}') pass
if terms: if body.get('terms'):
logger.info(f"Renewing member {username} for terms {terms}") user.add_terms(body['terms'])
user.add_terms(terms)
unexpire(user) unexpire(user)
return {'terms_added': terms} return {'terms_added': body['terms']}
elif non_member_terms: elif body.get('non_member_terms'):
logger.info(f"Renewing club rep {username} for non-member terms {non_member_terms}") user.add_non_member_terms(body['non_member_terms'])
user.add_non_member_terms(non_member_terms)
unexpire(user) unexpire(user)
return {'non_member_terms_added': non_member_terms} return {'non_member_terms_added': body['non_member_terms']}
else: else:
raise BadRequest('Must specify either terms or non-member terms') raise BadRequest('Must specify either terms or non-member terms')
@ -158,12 +130,9 @@ def reset_user_password(username: str):
@bp.route('/<username>', methods=['DELETE']) @bp.route('/<username>', methods=['DELETE'])
@requires_admin_creds
@authz_restrict_to_syscom @authz_restrict_to_syscom
@development_only @development_only
def delete_user(username: str): def delete_user(username: str):
# We use the admin creds for the integration tests for the web app, which
# uses the ceod/<host> key
txn = DeleteMemberTransaction(username) txn = DeleteMemberTransaction(username)
return create_streaming_response(txn) return create_streaming_response(txn)
@ -180,13 +149,11 @@ def expire_users():
if not dry_run: if not dry_run:
for member in members: for member in members:
logger.info(f'Expiring {member.uid}')
member.set_expired(True) member.set_expired(True)
try: try:
member.unsubscribe_from_mailing_list(member_list) member.unsubscribe_from_mailing_list(member_list)
logger.debug(f'Unsubscribed {member.uid} from {member_list}')
except UserNotSubscribedError: except UserNotSubscribedError:
logger.debug(f'{member.uid} is already unsubscribed from {member_list}') pass
return jsonify([member.uid for member in members]) return jsonify([member.uid for member in members])

View File

@ -2,7 +2,6 @@ from flask import Blueprint, request
from zope import component from zope import component
from .utils import authz_restrict_to_syscom, create_streaming_response from .utils import authz_restrict_to_syscom, create_streaming_response
from ceo_common.errors import BadRequest
from ceo_common.interfaces import ILDAPService, IConfig from ceo_common.interfaces import ILDAPService, IConfig
from ceod.transactions.members import UpdateMemberPositionsTransaction from ceod.transactions.members import UpdateMemberPositionsTransaction
@ -16,9 +15,7 @@ def get_positions():
positions = {} positions = {}
for user in ldap_srv.get_users_with_positions(): for user in ldap_srv.get_users_with_positions():
for position in user.positions: for position in user.positions:
if position not in positions: positions[position] = user.uid
positions[position] = []
positions[position].append(user.uid)
return positions return positions
@ -32,31 +29,23 @@ def update_positions():
required = cfg.get('positions_required') required = cfg.get('positions_required')
available = cfg.get('positions_available') available = cfg.get('positions_available')
# remove falsy values and parse multiple users in each position # remove falsy values
# Example: "user1,user2, user3" -> ["user1","user2","user3"] body = {
position_to_usernames = {} positions: username for positions, username in body.items()
for position, usernames in body.items(): if username
if not usernames: }
continue
if type(usernames) is list:
position_to_usernames[position] = usernames
elif type(usernames) is str:
position_to_usernames[position] = usernames.replace(' ', '').split(',')
else:
raise BadRequest('usernames must be a list or comma-separated string')
# check for duplicates (i.e. one username specified twice in the same list) for position in body.keys():
for usernames in position_to_usernames.values():
if len(usernames) != len(set(usernames)):
raise BadRequest('username may only be specified at most once for a position')
for position in position_to_usernames.keys():
if position not in available: if position not in available:
raise BadRequest(f'unknown position: {position}') return {
'error': f'unknown position: {position}'
}, 400
for position in required: for position in required:
if position not in position_to_usernames: if position not in body:
raise BadRequest(f'missing required position: {position}') return {
'error': f'missing required position: {position}'
}, 400
txn = UpdateMemberPositionsTransaction(position_to_usernames) txn = UpdateMemberPositionsTransaction(body)
return create_streaming_response(txn) return create_streaming_response(txn)

View File

@ -1,9 +1,12 @@
import functools import functools
import grp
import json import json
import os
import pwd
import traceback import traceback
from typing import Callable, List from typing import Callable, List
from flask import current_app, g, stream_with_context from flask import current_app, stream_with_context
from zope import component from zope import component
from .spnego import requires_authentication from .spnego import requires_authentication
@ -37,29 +40,9 @@ def requires_authentication_no_realm(f: Callable) -> Callable:
return wrapper return wrapper
def requires_admin_creds(f: Callable) -> Callable: def user_is_in_group(user: str, group: str) -> bool:
""" """Returns True if `user` is in `group`, False otherwise."""
Forces the next LDAP connection to use the admin Kerberos credentials. return user in grp.getgrnam(group).gr_mem
This must be used BEFORE any of the authz decorators, since those
may require an LDAP connection, which will get cached for later use.
"""
@functools.wraps(f)
def wrapper(*args, **kwargs):
g.need_admin_creds = True
return f(*args, **kwargs)
return wrapper
def user_is_in_group(username: str, group_name: str) -> bool:
"""
Returns True if `username` is in `group_name` (or starts with "ceod/"),
False otherwise.
"""
if username.startswith("ceod/"):
return True
ldap_srv = component.getUtility(ILDAPService)
group = ldap_srv.get_group(group_name)
return username in group.members
def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable: def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable:
@ -68,6 +51,8 @@ def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable
specified groups. specified groups.
""" """
allowed_group_ids = [grp.getgrnam(g).gr_gid for g in allowed_groups]
@requires_authentication_no_realm @requires_authentication_no_realm
@functools.wraps(f) @functools.wraps(f)
def wrapper(_username: str, *args, **kwargs): def wrapper(_username: str, *args, **kwargs):
@ -77,14 +62,15 @@ def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable
if username.startswith('ceod/'): if username.startswith('ceod/'):
# ceod services are always allowed to make internal calls # ceod services are always allowed to make internal calls
return f(*args, **kwargs) return f(*args, **kwargs)
ldap_srv = component.getUtility(ILDAPService) for gid in os.getgrouplist(username, pwd.getpwnam(username).pw_gid):
for group_name in ldap_srv.get_groups_for_user(username): if gid in allowed_group_ids:
if group_name in allowed_groups:
return f(*args, **kwargs) return f(*args, **kwargs)
logger.debug( logger.debug(
f"User '{username}' denied since they are not in one of {allowed_groups}" 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 {
'error': f'You must be in one of {allowed_groups}'
}, 403
return wrapper return wrapper
@ -144,7 +130,7 @@ def create_streaming_response(txn: AbstractTransaction):
def development_only(f: Callable) -> Callable: def development_only(f: Callable) -> Callable:
@functools.wraps(f) @functools.wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if current_app.config.get('DEBUG') or \ if current_app.config.get('ENV') == 'development' or \
current_app.config.get('TESTING'): current_app.config.get('TESTING'):
return f(*args, **kwargs) return f(*args, **kwargs)
return { return {

View File

@ -3,25 +3,15 @@ from flask.json import jsonify
from zope import component from zope import component
from .utils import authz_restrict_to_syscom, is_truthy from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService from ceo_common.interfaces import IUWLDAPService, ILDAPService
from ceo_common.logger_factory import logger_factory
bp = Blueprint('uwldap', __name__) bp = Blueprint('uwldap', __name__)
logger = logger_factory(__name__)
@bp.route('/<username>') @bp.route('/<username>')
def get_user(username: str): def get_user(username: str):
uwldap_srv = component.getUtility(IUWLDAPService) uwldap_srv = component.getUtility(IUWLDAPService)
record = uwldap_srv.get_user(username) record = uwldap_srv.get_user(username)
if record is not None and not record.sn:
# Alumni are missing a lot of information in UWLDAP.
# Try AD LDAP instead
logger.debug('Querying %s from AD LDAP', username)
adldap_srv = component.getUtility(IADLDAPService)
ad_record = adldap_srv.get_user(username)
if ad_record is not None:
record = ad_record.to_uwldap_record()
if record is None: if record is None:
return { return {
'error': 'user not found', 'error': 'user not found',

View File

@ -1,57 +0,0 @@
from typing import Optional
import dns.resolver
import ldap3
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IADLDAPService, IConfig
from ceo_common.logger_factory import logger_factory
from ceo_common.model import ADLDAPRecord
from ceo_common.utils import is_in_development
logger = logger_factory(__name__)
@implementer(IADLDAPService)
class ADLDAPService:
def __init__(self):
cfg = component.getUtility(IConfig)
if is_in_development():
self.adldap_dns_srv_name = None
self.adldap_server_url = cfg.get('adldap_server_url')
else:
self.adldap_dns_srv_name = cfg.get('adldap_dns_srv_name')
# Perform the actual DNS query later so that we don't delay startup
self.adldap_server_url = None
self.adldap_base = cfg.get('adldap_base')
def _get_server_url(self) -> str:
assert self.adldap_dns_srv_name is not None
answers = dns.resolver.resolve(self.adldap_dns_srv_name, 'SRV')
target = answers[0].target.to_text()
# Strip the trailing '.'
target = target[:-1]
logger.debug('Using AD LDAP server %s', target)
# ldaps doesn't seem to work
return 'ldap://' + target
def _get_conn(self) -> ldap3.Connection:
if self.adldap_server_url is None:
self.adldap_server_url = self._get_server_url()
# When get_info=ldap3.SCHEMA (the default), ldap3 tries to search
# for schema information in 'CN=Schema,CN=Configuration,DC=ds,DC=uwaterloo,DC=ca',
# which doesn't exist so a LDAPNoSuchObjectResult exception is raised.
# To avoid this, we tell ldap3 not to look up any server info.
server = ldap3.Server(self.adldap_server_url, get_info=ldap3.NONE)
return ldap3.Connection(
server, auto_bind=True, read_only=True, raise_exceptions=True)
def get_user(self, username: str) -> Optional[ADLDAPRecord]:
conn = self._get_conn()
conn.search(
self.adldap_base, f'(sAMAccountName={username})',
attributes=ADLDAPRecord.ldap_attributes, size_limit=1)
if not conn.entries:
return None
return ADLDAPRecord.deserialize_from_ldap(conn.entries[0])

View File

@ -97,7 +97,7 @@ class CloudResourceManager:
resources = accounts[username]['resources'] resources = accounts[username]['resources']
if 'cloudstack' in resources: if 'cloudstack' in resources:
account_id = accounts[username]['cloudstack_account_id'] account_id = accounts[username]['cloudstack_account_id']
cloudstack_srv.delete_account(username, account_id) cloudstack_srv.delete_account(account_id)
if 'vhost' in resources: if 'vhost' in resources:
vhost_mgr.delete_all_vhosts_for_user(username) vhost_mgr.delete_all_vhosts_for_user(username)
if 'k8s' in resources: if 'k8s' in resources:

View File

@ -62,7 +62,6 @@ class CloudStackService:
domain_id = self._get_domain_id() domain_id = self._get_domain_id()
url = self._create_url({ url = self._create_url({
'command': 'listAccounts', 'command': 'listAccounts',
'accounttype': '0', # regular user (exclude domain admin)
'domainid': domain_id, 'domainid': domain_id,
'details': 'min', 'details': 'min',
}) })
@ -77,8 +76,7 @@ class CloudStackService:
for account in d['account'] for account in d['account']
} }
def delete_account(self, username: str, account_id: str): def delete_account(self, account_id: str):
logger.info(f'Deleting CloudStack account for {username}')
url = self._create_url({ url = self._create_url({
'command': 'deleteAccount', 'command': 'deleteAccount',
'id': account_id, 'id': account_id,

View File

@ -84,19 +84,16 @@ class ClubWebHostingService:
logger.debug('Reloading Apache') logger.debug('Reloading Apache')
self._run(['systemctl', 'reload', 'apache2']) self._run(['systemctl', 'reload', 'apache2'])
# This requires the APACHE_CONFIG_CRON environment variable to be
# set to 1 (e.g. in a systemd drop-in)
# See /etc/apache2/.git/hooks/pre-commit on caffeine
def _git_commit(self): def _git_commit(self):
if not os.path.isdir(os.path.join(self.apache_dir, '.git')): if not os.path.isdir(os.path.join(self.apache_dir, '.git')):
logger.debug('No git folder found in Apache directory') logger.debug('No git folder found in Apache directory')
return return
logger.debug('Committing changes to git repository') logger.debug('Committing changes to git repository')
self._run( self._run(['git', 'add', APACHE_DISABLED_CLUBS_FILE], cwd=self.apache_dir)
['git', 'add', APACHE_DISABLED_CLUBS_FILE], self._run(['git', 'commit', '-m', '[ceo] disable club websites'], cwd=self.apache_dir)
cwd=self.apache_dir)
# See /etc/apache2/.git/hooks/pre-commit on caffeine
self._run(
['git', 'commit', '-m', '[ceo] disable club websites'],
cwd=self.apache_dir,
env={**os.environ, 'APACHE_CONFIG_CRON': '1'})
def commit(self): def commit(self):
if not self.made_at_least_one_change: if not self.made_at_least_one_change:
@ -115,13 +112,12 @@ class ClubWebHostingService:
directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive') directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive')
for directive_path in directive_paths: for directive_path in directive_paths:
directive = self.aug.get(directive_path) directive = self.aug.get(directive_path)
directive_value = self.aug.get(directive_path + '/arg')
if directive == 'DocumentRoot': if directive == 'DocumentRoot':
directive_value = self.aug.get(directive_path + '/arg')
match = APACHE_USERDIR_RE.match(directive_value) match = APACHE_USERDIR_RE.match(directive_value)
if match is not None: if match is not None:
club_name = match.group('club_name') club_name = match.group('club_name')
elif directive == 'ServerAdmin': elif directive == 'ServerAdmin':
directive_value = self.aug.get(directive_path + '/arg')
club_email = directive_value club_email = directive_value
if club_name is not None: if club_name is not None:
self.clubs[club_name]['email'] = club_email self.clubs[club_name]['email'] = club_email
@ -161,20 +157,12 @@ class ClubWebHostingService:
def _site_uses_php(self, club_name: str) -> bool: def _site_uses_php(self, club_name: str) -> bool:
www = f'{self.clubs_home}/{club_name}/www' www = f'{self.clubs_home}/{club_name}/www'
if not os.path.isdir(www): if os.path.isdir(www):
return False
try:
# We're just going to look one level deep; that should be good enough. # We're just going to look one level deep; that should be good enough.
filenames = os.listdir(www) for filename in os.listdir(www):
except os.error: filepath = os.path.join(www, filename)
# If we're unable to read the directory (e.g. permissions error), if os.path.isfile(filepath) and filename.endswith('.php'):
# then this means that the Apache user (www-data) can't read it either. return True
# So we can just return False here.
return False
for filename in filenames:
filepath = os.path.join(www, filename)
if os.path.isfile(filepath) and filename.endswith('.php'):
return True
return False return False
# This method needs to be called from within a transaction (uses self.clubs) # This method needs to be called from within a transaction (uses self.clubs)
@ -236,7 +224,7 @@ class ClubWebHostingService:
# STEP 2: send emails to clubs whose websites were disabled # STEP 2: send emails to clubs whose websites were disabled
clubs_who_were_not_notified = set() clubs_who_were_not_notified = set()
for club_name in clubs_to_disable: for club_name in clubs_to_disable:
address = clubs_info[club_name]['email'] address = clubs_info['email']
if address is None: if address is None:
clubs_who_were_not_notified.add(club_name) clubs_who_were_not_notified.add(club_name)
continue continue

View File

@ -7,9 +7,6 @@ from zope.interface import implementer
from ceo_common.errors import UserNotFoundError from ceo_common.errors import UserNotFoundError
from ceo_common.interfaces import IContainerRegistryService, IConfig from ceo_common.interfaces import IContainerRegistryService, IConfig
from ceo_common.logger_factory import logger_factory
logger = logger_factory(__name__)
@implementer(IContainerRegistryService) @implementer(IContainerRegistryService)
@ -73,7 +70,6 @@ class ContainerRegistryService:
resp.raise_for_status() resp.raise_for_status()
def delete_project_for_user(self, username: str): def delete_project_for_user(self, username: str):
logger.info(f'Deleting Harbor project for {username}')
# Delete all of the repositories inside the project first # Delete all of the repositories inside the project first
resp = self._http_get(f'/projects/{username}/repositories') resp = self._http_get(f'/projects/{username}/repositories')
if resp.status_code == 403: if resp.status_code == 403:

View File

@ -11,9 +11,6 @@ from zope import component
from zope.interface import implementer from zope.interface import implementer
from ceo_common.interfaces import IConfig, IKubernetesService from ceo_common.interfaces import IConfig, IKubernetesService
from ceo_common.logger_factory import logger_factory
logger = logger_factory(__name__)
@implementer(IKubernetesService) @implementer(IKubernetesService)
@ -103,7 +100,6 @@ class KubernetesService:
return body return body
def delete_account(self, username: str): def delete_account(self, username: str):
logger.info(f'Deleting Kubernetes namespace for {username}')
namespace = self._get_namespace(username) namespace = self._get_namespace(username)
# don't check exit code because namespace might not exist # don't check exit code because namespace might not exist
self._run(['kubectl', 'delete', 'namespace', namespace], check=False) self._run(['kubectl', 'delete', 'namespace', namespace], check=False)

View File

@ -98,13 +98,6 @@ class LDAPService:
entry = self._get_readable_entry_for_group(conn, cn) entry = self._get_readable_entry_for_group(conn, cn)
return Group.deserialize_from_ldap(entry) return Group.deserialize_from_ldap(entry)
def get_groups_for_user(self, username: str) -> List[str]:
conn = self._get_ldap_conn()
conn.search(self.ldap_groups_base,
f'(uniqueMember={self.uid_to_dn(username)})',
attributes=['cn'])
return sorted([entry.cn.value for entry in conn.entries])
def get_display_info_for_users(self, usernames: List[str]) -> List[Dict[str, str]]: def get_display_info_for_users(self, usernames: List[str]) -> List[Dict[str, str]]:
if not usernames: if not usernames:
return [] return []
@ -112,14 +105,14 @@ class LDAPService:
filter = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')' filter = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
attributes = ['uid', 'cn', 'program'] attributes = ['uid', 'cn', 'program']
conn.search(self.ldap_users_base, filter, attributes=attributes) conn.search(self.ldap_users_base, filter, attributes=attributes)
return sorted([ return [
{ {
'uid': entry.uid.value, 'uid': entry.uid.value,
'cn': entry.cn.value, 'cn': entry.cn.value,
'program': entry.program.value or 'Unknown', 'program': entry.program.value or 'Unknown',
} }
for entry in conn.entries for entry in conn.entries
], key=lambda member: member['uid']) ]
def get_users_with_positions(self) -> List[IUser]: def get_users_with_positions(self) -> List[IUser]:
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
@ -316,14 +309,12 @@ class LDAPService:
self, self,
dry_run: bool = False, dry_run: bool = False,
members: Union[List[str], None] = None, members: Union[List[str], None] = None,
# The UWLDAP server currently has a result set limit of 50
# Keep it low just to be safe
uwldap_batch_size: int = 10, uwldap_batch_size: int = 10,
): ):
if members: if members:
filter = '(|' + ''.join([f'(uid={uid})' for uid in members]) + ')' filter = '(|' + ''.join([f'(uid={uid})' for uid in members]) + ')'
else: else:
filter = '(objectClass=member)' filter = '(objectClass=*)'
conn = self._get_ldap_conn() conn = self._get_ldap_conn()
conn.search( conn.search(
self.ldap_users_base, filter, attributes=['uid', 'program']) self.ldap_users_base, filter, attributes=['uid', 'program'])
@ -338,17 +329,12 @@ class LDAPService:
batch_uids = uids[i:i + uwldap_batch_size] batch_uids = uids[i:i + uwldap_batch_size]
batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids) batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids)
uw_programs.extend(batch_uw_programs) uw_programs.extend(batch_uw_programs)
# uw_programs[i] will be None if the 'ou' attribute was not
# present in UWLDAP, or if no UWLDAP entry was found at all
for i, uw_program in enumerate(uw_programs):
if uw_program in (None, 'expired', 'orphaned'):
# If the UWLDAP record is orphaned, nonexistent, or missing
# data, assume that the member graduated
uw_programs[i] = 'Alumni'
users_to_change = [ users_to_change = [
(uids[i], csc_programs[i], uw_programs[i]) (uids[i], csc_programs[i], uw_programs[i])
for i in range(len(uids)) for i in range(len(uids))
if csc_programs[i] != uw_programs[i] if csc_programs[i] != uw_programs[i] and (
uw_programs[i] not in (None, 'expired', 'orphaned')
)
] ]
if dry_run: if dry_run:
return users_to_change return users_to_change
@ -360,11 +346,7 @@ class LDAPService:
return users_to_change return users_to_change
def _get_club_uids(self, conn: ldap3.Connection) -> List[str]: def _get_club_uids(self, conn: ldap3.Connection) -> List[str]:
conn.extend.standard.paged_search(self.ldap_users_base, conn.search(self.ldap_users_base, '(objectClass=club)', attributes=['uid'])
'(objectClass=club)',
attributes=['uid'],
paged_size=50,
generator=False)
return [entry.uid.value for entry in conn.entries] return [entry.uid.value for entry in conn.entries]
def get_clubs(self) -> List[IGroup]: def get_clubs(self) -> List[IGroup]:

View File

@ -1,11 +1,11 @@
from typing import List, Optional from typing import Union, List
import ldap3 import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from .UWLDAPRecord import UWLDAPRecord
from ceo_common.interfaces import IUWLDAPService, IConfig from ceo_common.interfaces import IUWLDAPService, IConfig
from ceo_common.model import UWLDAPRecord
@implementer(IUWLDAPService) @implementer(IUWLDAPService)
@ -20,7 +20,7 @@ class UWLDAPService:
self.uwldap_server_url, auto_bind=True, read_only=True, self.uwldap_server_url, auto_bind=True, read_only=True,
raise_exceptions=True) raise_exceptions=True)
def get_user(self, username: str) -> Optional[UWLDAPRecord]: def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
conn = self._get_conn() conn = self._get_conn()
conn.search( conn.search(
self.uwldap_base, f'(uid={username})', self.uwldap_base, f'(uid={username})',
@ -29,7 +29,7 @@ class UWLDAPService:
return None return None
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0]) return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]: def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')' filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
programs = [None] * len(usernames) programs = [None] * len(usernames)
user_indices = {uid: i for i, uid in enumerate(usernames)} user_indices = {uid: i for i, uid in enumerate(usernames)}

View File

@ -86,6 +86,7 @@ class User:
'is_club': self.is_club(), 'is_club': self.is_club(),
'is_club_rep': self.is_club_rep, 'is_club_rep': self.is_club_rep,
'program': self.program or 'Unknown', 'program': self.program or 'Unknown',
'shadowExpire': self.shadowExpire,
} }
if self.sn and self.given_name: if self.sn and self.given_name:
data['sn'] = self.sn data['sn'] = self.sn
@ -102,8 +103,6 @@ class User:
data['mail_local_addresses'] = self.mail_local_addresses data['mail_local_addresses'] = self.mail_local_addresses
if get_forwarding_addresses: if get_forwarding_addresses:
data['forwarding_addresses'] = self.get_forwarding_addresses() data['forwarding_addresses'] = self.get_forwarding_addresses()
if self.shadowExpire:
data['shadow_expire'] = self.shadowExpire
return data return data
def __repr__(self) -> str: def __repr__(self) -> str:
@ -170,7 +169,7 @@ class User:
is_club_rep=attrs.get('isClubRep', [False])[0], is_club_rep=attrs.get('isClubRep', [False])[0],
is_club=('club' in attrs['objectClass']), is_club=('club' in attrs['objectClass']),
is_member_or_club_rep=('member' in attrs['objectClass']), is_member_or_club_rep=('member' in attrs['objectClass']),
shadowExpire=attrs.get('shadowExpire', [None])[0], shadowExpire=attrs.get('shadowExpire'),
ldap3_entry=entry, ldap3_entry=entry,
) )

View File

@ -4,7 +4,7 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
from typing import List, Dict, Tuple, Union from typing import List, Dict, Tuple
import jinja2 import jinja2
from zope import component from zope import component
@ -53,7 +53,6 @@ class VHostManager:
self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account') self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account')
self.vhost_ip_min = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_min')) self.vhost_ip_min = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_min'))
self.vhost_ip_max = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_max')) self.vhost_ip_max = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_max'))
self.reload_web_server_cmd = cfg.get('cloud vhosts_reload_web_server_cmd')
self.acme_challenge_dir = cfg.get('cloud vhosts_acme_challenge_dir') self.acme_challenge_dir = cfg.get('cloud vhosts_acme_challenge_dir')
self.acme_dir = '/root/.acme.sh' self.acme_dir = '/root/.acme.sh'
@ -83,12 +82,12 @@ class VHostManager:
"""Return a list of all vhost files for this user.""" """Return a list of all vhost files for this user."""
return glob.glob(os.path.join(self.vhost_dir, username + '_*')) return glob.glob(os.path.join(self.vhost_dir, username + '_*'))
def _run(self, args: Union[List[str], str], **kwargs): def _run(self, args: List[str]):
subprocess.run(args, check=True, **kwargs) subprocess.run(args, check=True)
def _reload_web_server(self): def _reload_web_server(self):
logger.debug('Reloading NGINX') logger.debug('Reloading NGINX')
self._run(self.reload_web_server_cmd, shell=True) self._run(['systemctl', 'reload', 'nginx'])
def is_valid_domain(self, username: str, domain: str) -> bool: def is_valid_domain(self, username: str, domain: str) -> bool:
if VALID_DOMAIN_RE.match(domain) is None: if VALID_DOMAIN_RE.match(domain) is None:
@ -151,7 +150,7 @@ class VHostManager:
self.acme_sh, '--install-cert', '-d', domain, self.acme_sh, '--install-cert', '-d', domain,
'--key-file', key_path, '--key-file', key_path,
'--fullchain-file', cert_path, '--fullchain-file', cert_path,
'--reloadcmd', self.reload_web_server_cmd, '--reloadcmd', 'systemctl reload nginx',
]) ])
def _delete_cert(self, domain: str, cert_path: str, key_path: str): def _delete_cert(self, domain: str, cert_path: str, key_path: str):

View File

@ -1,4 +1,3 @@
from .ADLDAPService import ADLDAPService
from .CloudResourceManager import CloudResourceManager from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService from .CloudStackService import CloudStackService
from .KerberosService import KerberosService from .KerberosService import KerberosService
@ -6,6 +5,7 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
from .User import User from .User import User
from .Group import Group from .Group import Group
from .UWLDAPService import UWLDAPService from .UWLDAPService import UWLDAPService
from .UWLDAPRecord import UWLDAPRecord
from .FileService import FileService from .FileService import FileService
from .SudoRole import SudoRole from .SudoRole import SudoRole
from .MailService import MailService from .MailService import MailService

View File

@ -1,5 +1,5 @@
from collections import defaultdict from collections import defaultdict
from typing import Dict, List from typing import Dict
from zope import component from zope import component
@ -20,19 +20,15 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
'subscribe_to_mailing_lists', 'subscribe_to_mailing_lists',
] ]
def __init__(self, position_to_usernames: Dict[str, List[str]]): def __init__(self, positions_reversed: Dict[str, str]):
# positions_reversed is position -> username # positions_reversed is position -> username
super().__init__() super().__init__()
self.ldap_srv = component.getUtility(ILDAPService) self.ldap_srv = component.getUtility(ILDAPService)
# Reverse the dict so it's easier to use (username -> positions) # Reverse the dict so it's easier to use (username -> positions)
self.positions = defaultdict(list) self.positions = defaultdict(list)
for position, usernames in position_to_usernames.items(): for position, username in positions_reversed.items():
if isinstance(usernames, list): self.positions[username].append(position)
for username in usernames:
self.positions[username].append(position)
else:
raise TypeError("Username(s) under each position must be a list")
# a cached Dict of the Users who need to be modified (username -> User) # a cached Dict of the Users who need to be modified (username -> User)
self.users: Dict[str, IUser] = {} self.users: Dict[str, IUser] = {}
@ -46,7 +42,7 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
mailing_lists = cfg.get('auxiliary mailing lists_exec') mailing_lists = cfg.get('auxiliary mailing lists_exec')
# position -> username # position -> username
new_position_to_usernames = {} # For returning result new_positions_reversed = {} # For returning result
# retrieve User objects and cache them # retrieve User objects and cache them
for username in self.positions: for username in self.positions:
@ -68,9 +64,7 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
self.old_positions[username] = old_positions self.old_positions[username] = old_positions
for position in new_positions: for position in new_positions:
if position not in new_position_to_usernames: new_positions_reversed[position] = username
new_position_to_usernames[position] = []
new_position_to_usernames[position].append(username)
yield 'update_positions_ldap' yield 'update_positions_ldap'
# update exec group in LDAP # update exec group in LDAP
@ -103,7 +97,7 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
else: else:
yield 'subscribe_to_mailing_lists' yield 'subscribe_to_mailing_lists'
self.finish(new_position_to_usernames) self.finish(new_positions_reversed)
def rollback(self): def rollback(self):
if 'update_exec_group_ldap' in self.finished_operations: if 'update_exec_group_ldap' in self.finished_operations:

2
debian/ceod.postinst vendored Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
chmod 600 /etc/csc/ceod.ini

76
debian/changelog vendored
View File

@ -1,79 +1,3 @@
ceo (1.0.31-bookworm1) bookworm; urgency=medium
* Allow office manager to manage office group
* Fix regression issue for new positions
-- Nathan <n4chung@csclub.uwaterloo.ca> Wed, 21 Feb 2024 06:40:29 +0000
ceo (1.0.30-bookworm1) bookworm; urgency=medium
* Support for new positions
* Improved username validation
* UWLDAP API integration for alumni information
-- Nathan <n4chung@csclub.uwaterloo.ca> Sun, 04 Feb 2024 19:38:18 +0000
ceo (1.0.29-bookworm1) bookworm; urgency=medium
* Upgrade dependencies
* Check that forwarding_addresses parameter is a list
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Tue, 01 Aug 2023 01:14:08 +0000
ceo (1.0.28-bullseye1) bullseye; urgency=medium
* Upgrade dependencies
* Check that forwarding_addresses parameter is a list
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Tue, 01 Aug 2023 00:07:38 +0000
ceo (1.0.27-bullseye1) bullseye; urgency=medium
* Make forwarding_addresses mandatory when creating new member
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Fri, 09 Jun 2023 06:43:10 +0000
ceo (1.0.26-bullseye1.1) bullseye; urgency=high
* Reduce UWLDAP batch size from 100 to 10
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Mon, 13 Feb 2023 22:35:47 +0000
ceo (1.0.25-bullseye1.1) bullseye; urgency=medium
* Support multiple users sharing the same position
* Show groups when retrieving user information
* Use admin Kerberos credentials when subscribing new member to csc-general
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Mon, 06 Feb 2023 05:01:46 +0000
ceo (1.0.24-bullseye1) bullseye; urgency=high
* Add support for using number in member terms renwewal API
* Sort group member listing by WatIAM ID
* Add more logging for Cloudstack
* Use LDAP instead of NSS
* Fix shadowExpire deserialization
* Fix email formatting bug in ClubWebHostingService
* Check if mail_local_addresses exists in UWLDAP entry
* Remove override_dh_systemd_start
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Sun, 23 Oct 2022 21:41:00 -0400
ceo (1.0.23-bullseye1) bullseye; urgency=high
* Fix some bugs in ClubWebHostingService.
-- Max Erenberg <merenber@csclub.uwaterloo.ca> Mon, 05 Sep 2022 03:57:47 +0000
ceo (1.0.22-bullseye1) bullseye; urgency=medium
* Implement renewal reminders.
* Allow addmember and removemember to accept multiple usernames.
* Disable inactive club sites
-- Raymond Li <raymo@csclub.uwaterloo.ca> Sat, 06 Aug 2022 01:43:08 +0000
ceo (1.0.21-bullseye1) bullseye; urgency=high ceo (1.0.21-bullseye1) bullseye; urgency=high
* Fix bug in ContainerRegistryService. * Fix bug in ContainerRegistryService.

2
debian/compat vendored
View File

@ -1 +1 @@
13 10

33
debian/control vendored
View File

@ -2,28 +2,26 @@ Source: ceo
Maintainer: Systems Committee <syscom@csclub.uwaterloo.ca> Maintainer: Systems Committee <syscom@csclub.uwaterloo.ca>
Section: admin Section: admin
Priority: optional Priority: optional
Standards-Version: 4.6.2 Standards-Version: 4.3.0
Vcs-Git: https://git.csclub.uwaterloo.ca/public/pyceo.git Vcs-Git: https://git.csclub.uwaterloo.ca/public/pyceo.git
Vcs-Browser: https://git.csclub.uwaterloo.ca/public/pyceo Vcs-Browser: https://git.csclub.uwaterloo.ca/public/pyceo
Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>, Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>
Raymond Li <raymo@csclub.uwaterloo.ca>, Build-Depends: debhelper (>= 12.1.1),
Nathan <n4chung@csclub.uwaterloo.ca>, python3-dev (>= 3.7),
Edwin <e42zhang@csclub.uwaterloo.ca> python3-venv (>= 3.7),
Build-Depends: debhelper (>= 13), libkrb5-dev (>= 1.17),
python3-dev (>= 3.9), libpq-dev (>= 11.13),
python3-venv (>= 3.9), libaugeas0 (>= 1.11),
libkrb5-dev (>= 1.18), scdoc (>= 1.9)
libpq-dev (>= 13.9),
libaugeas0 (>= 1.12),
scdoc (>= 1.11)
Package: ceo-common Package: ceo-common
Architecture: amd64 Architecture: amd64
Depends: python3 (>= 3.9), Depends: python3 (>= 3.7),
krb5-user (>= 1.18), krb5-user (>= 1.17),
libkrb5-3 (>= 1.18), libkrb5-3 (>= 1.17),
libpq5 (>= 13.9), libpq5 (>= 11.13),
libaugeas0 (>= 1.12), libaugeas0 (>= 1.11),
${python3:Depends},
${misc:Depends} ${misc:Depends}
Description: CSC Electronic Office common files Description: CSC Electronic Office common files
This package contains the common files for the CSC Electronic Office. This package contains the common files for the CSC Electronic Office.
@ -41,7 +39,6 @@ Package: ceod
Architecture: amd64 Architecture: amd64
Replaces: ceo-daemon Replaces: ceo-daemon
Conflicts: ceo-daemon Conflicts: ceo-daemon
Pre-Depends: ${misc:Pre-Depends}
Depends: ceo-common (= ${source:Version}), openssl (>= 1.1.1), ${misc:Depends} Depends: ceo-common (= ${source:Version}), openssl (>= 1.1.1), ${misc:Depends}
Description: CSC Electronic Office daemon Description: CSC Electronic Office daemon
This package contains the daemon for the CSC Electronic Office. This package contains the daemon for the CSC Electronic Office.

11
debian/rules vendored
View File

@ -5,14 +5,7 @@
override_dh_strip: override_dh_strip:
override_dh_strip_nondeterminism:
override_dh_shlibdeps: override_dh_shlibdeps:
override_dh_makeshlibs: override_dh_systemd_start:
dh_systemd_start --no-start ceod.service
override_dh_dwz:
override_dh_fixperms:
dh_fixperms
chmod 600 $(CURDIR)/debian/ceod/etc/csc/ceod.ini

View File

@ -1,6 +1,6 @@
flake8==6.1.0 flake8==3.9.2
setuptools==68.0.0 setuptools==40.8.0
wheel==0.41.0 wheel==0.36.2
pytest==7.4.0 pytest==6.2.4
aiosmtpd==1.4.2 aiosmtpd==1.4.2
aiohttp==3.8.5 aiohttp==3.7.4.post0

View File

@ -1,54 +1,39 @@
version: "3.6" version: "3.6"
x-common: &common x-common: &common
image: python:3.7-buster
volumes: volumes:
- ./.drone:/app/.drone:ro - .:$PWD:z
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro
- ceo-venv:/app/venv:ro
- ./ceo:/app/ceo:ro
- ./ceo_common:/app/ceo_common:ro
- ./ceod:/app/ceod:ro
- ./tests:/app/tests:ro
# for flake8
- ./setup.cfg:/app/setup.cfg:ro
- ./web:/app/web:z
security_opt:
- label:disable
working_dir: /app
entrypoint:
- ./docker-entrypoint.sh
x-ceod-common: &ceod-common
<<: *common
image: ceo-generic:bullseye
environment: environment:
FLASK_APP: ceod.api FLASK_APP: ceod.api
FLASK_DEBUG: "true" FLASK_ENV: development
working_dir: $PWD
entrypoint:
- ./docker-entrypoint.sh
services: services:
auth1: auth1:
<<: *common <<: *common
image: ceo-auth1:bullseye image: debian:buster
hostname: auth1 hostname: auth1
command: auth1 command: auth1
coffee: coffee:
<<: *ceod-common <<: *common
image: ceo-coffee:bullseye
command: coffee command: coffee
hostname: coffee hostname: coffee
depends_on: depends_on:
- auth1 - auth1
mail: mail:
<<: *ceod-common <<: *common
command: mail command: mail
hostname: mail hostname: mail
depends_on: depends_on:
- auth1 - auth1
phosphoric-acid: phosphoric-acid:
<<: *ceod-common <<: *common
command: phosphoric-acid command: phosphoric-acid
hostname: phosphoric-acid hostname: phosphoric-acid
depends_on: depends_on:
@ -56,8 +41,4 @@ services:
- coffee - coffee
- mail - mail
volumes:
ceo-venv:
external: true
# vim: expandtab sw=2 ts=2 # vim: expandtab sw=2 ts=2

View File

@ -1,21 +1,16 @@
#!/bin/bash #!/bin/sh -e
set -ex
if [ $# -ne 1 ]; then
echo "Usage: $0 <host>" >&2
exit 1
fi
host="$1"
if ! [ -d venv ]; then if ! [ -d venv ]; then
echo "You need to mount the virtualenv" >&2 echo "You need to create the virtualenv first!" >&2
exit 1 exit 1
fi fi
. .drone/$host-setup.sh
CONTAINER__setup host="$1"
[ -x ".drone/$host-setup.sh" ] && ".drone/$host-setup.sh"
if [ "$host" = auth1 ]; then if [ "$host" = auth1 ]; then
exec sleep infinity exec sleep infinity
else else
exec .drone/supervise.sh venv/bin/flask run -h 0.0.0.0 -p 9987 . venv/bin/activate
exec .drone/supervise.sh flask run -h 0.0.0.0 -p 9987
fi fi

View File

@ -61,9 +61,9 @@ paths:
program: program:
$ref: "#/components/schemas/Program" $ref: "#/components/schemas/Program"
terms: terms:
$ref: "#/components/schemas/TermsOrNumTerms" $ref: "#/components/schemas/Terms"
non_member_terms: non_member_terms:
$ref: "#/components/schemas/TermsOrNumTerms" $ref: "#/components/schemas/NonMemberTerms"
forwarding_addresses: forwarding_addresses:
$ref: "#/components/schemas/ForwardingAddresses" $ref: "#/components/schemas/ForwardingAddresses"
responses: responses:
@ -161,11 +161,17 @@ paths:
- type: object - type: object
properties: properties:
terms: terms:
$ref: "#/components/schemas/TermsOrNumTerms" type: array
description: Terms for which this user will be a member
items:
$ref: "#/components/schemas/Term"
- type: object - type: object
properties: properties:
non_member_terms: non_member_terms:
$ref: "#/components/schemas/TermsOrNumTerms" type: array
description: Terms for which this user will be a club rep
items:
$ref: "#/components/schemas/Term"
example: {"terms": ["f2021"]} example: {"terms": ["f2021"]}
responses: responses:
"200": "200":
@ -284,26 +290,6 @@ paths:
$ref: "#/components/schemas/UID" $ref: "#/components/schemas/UID"
"404": "404":
$ref: "#/components/responses/GroupNotFoundErrorResponse" $ref: "#/components/responses/GroupNotFoundErrorResponse"
/groups/{query}/{count}:
get:
tags: ['groups']
summary: fuzzy search groups
description: >-
search count number of groups, returns a list of names sorted by levenshtein edit
distance.
parameters:
- name: query
in: path
description: query or string to search for
required: true
schema:
type: string
- name: count
in: path
description: number of results to return, returns empty strings if necessary
required: true
schema:
type: int
/groups/{group_name}/members/{username}: /groups/{group_name}/members/{username}:
post: post:
tags: ['groups'] tags: ['groups']
@ -397,14 +383,11 @@ paths:
schema: schema:
type: object type: object
additionalProperties: additionalProperties:
type: array type: string
description: list of usernames
items:
type: string
example: example:
president: ["user1"] president: user0
vice-president: ["user2", "user3"] vice-president: user1
sysadmin: ["user4"] sysadmin: user2
treasurer: treasurer:
post: post:
tags: ['positions'] tags: ['positions']
@ -421,18 +404,11 @@ paths:
schema: schema:
type: object type: object
additionalProperties: additionalProperties:
oneOf: type: string
- type: string
description: username or comma-separated list of usernames
- type: array
description: list of usernames
items:
type: string
example: example:
president: user1 president: user0
vice-president: user2, user3 vice-president: user1
secretary: ["user4", "user5"] sysadmin: user2
sysadmin: ["user6"]
treasurer: treasurer:
responses: responses:
"200": "200":
@ -446,7 +422,7 @@ paths:
{"status": "in progress", "operation": "update_positions_ldap"} {"status": "in progress", "operation": "update_positions_ldap"}
{"status": "in progress", "operation": "update_exec_group_ldap"} {"status": "in progress", "operation": "update_exec_group_ldap"}
{"status": "in progress", "operation": "subscribe_to_mailing_list"} {"status": "in progress", "operation": "subscribe_to_mailing_list"}
{"status": "completed", "result": {"president": ["user1"],"vice-president": ["user2", "user3"],"secretary": ["user4". "user5"],"sysadmin": ["user6"]}} {"status": "completed", "result": "OK"}
"400": "400":
description: Failed description: Failed
content: content:
@ -907,15 +883,14 @@ components:
example: MAT/Mathematics Computer Science example: MAT/Mathematics Computer Science
Terms: Terms:
type: array type: array
description: List of terms description: Terms for which this user was a member
items:
$ref: "#/components/schemas/Term"
NonMemberTerms:
type: array
description: Terms for which this user was a club rep
items: items:
$ref: "#/components/schemas/Term" $ref: "#/components/schemas/Term"
TermsOrNumTerms:
oneOf:
- type: integer
description: number of additional terms to add
example: 1
- $ref: "#/components/schemas/Terms"
LoginShell: LoginShell:
type: string type: string
description: Login shell description: Login shell
@ -964,14 +939,9 @@ components:
terms: terms:
$ref: "#/components/schemas/Terms" $ref: "#/components/schemas/Terms"
non_member_terms: non_member_terms:
$ref: "#/components/schemas/Terms" $ref: "#/components/schemas/NonMemberTerms"
forwarding_addresses: forwarding_addresses:
$ref: "#/components/schemas/ForwardingAddresses" $ref: "#/components/schemas/ForwardingAddresses"
groups:
type: array
description: Groups for which this user is a member of
items:
$ref: "#/components/schemas/GroupCN"
UWLDAPUser: UWLDAPUser:
type: object type: object
properties: properties:

File diff suppressed because one or more lines are too long

View File

@ -18,11 +18,8 @@ port = 9987
[positions] [positions]
required = president,vice-president,sysadmin required = president,vice-president,sysadmin
available = president,vice-president,sysadmin,treasurer, available = president,vice-president,treasurer,secretary,
secretary,cro,webmaster,offsck,ext-affairs-lead, sysadmin,cro,librarian,imapd,webmaster,offsck
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql] [mysql]
host = caffeine host = caffeine

View File

@ -30,10 +30,6 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
server_url = ldaps://uwldap.uwaterloo.ca server_url = ldaps://uwldap.uwaterloo.ca
base = dc=uwaterloo,dc=ca base = dc=uwaterloo,dc=ca
[adldap]
dns_srv_name = _ldap._tcp.teaching.ds.uwaterloo.ca
base = dc=teaching,dc=ds,dc=uwaterloo,dc=ca
[members] [members]
min_id = 20001 min_id = 20001
max_id = 29999 max_id = 29999
@ -67,10 +63,8 @@ exec = exec,exec-moderators
[positions] [positions]
required = president,vice-president,sysadmin required = president,vice-president,sysadmin
available = president,vice-president,sysadmin,treasurer, available = president,vice-president,treasurer,secretary,
secretary,cro,webmaster,offsck,ext-affairs-lead, sysadmin,cro,librarian,imapd,webmaster,offsck
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql] [mysql]
# This is only used on the database_host. # This is only used on the database_host.
@ -103,7 +97,6 @@ members_domain = csclub.cloud
k8s_members_domain = k8s.csclub.cloud k8s_members_domain = k8s.csclub.cloud
ip_range_min = 172.19.134.10 ip_range_min = 172.19.134.10
ip_range_max = 172.19.134.160 ip_range_max = 172.19.134.160
reload_web_server_cmd = /root/bin/reload-nginx.sh
[k8s] [k8s]
members_clusterrole = csc-members-default members_clusterrole = csc-members-default

View File

@ -1,17 +1,16 @@
click==8.1.6 click==8.1.3
cryptography==41.0.2 cryptography==35.0.0
dnspython==2.5.0 Flask==2.1.2
Flask==2.3.2 gssapi==1.6.14
gssapi==1.8.2 gunicorn==20.1.0
gunicorn==21.2.0
Jinja2==3.1.2 Jinja2==3.1.2
ldap3==2.9.1 ldap3==2.9.1
mysql-connector-python==8.1.0 mysql-connector-python==8.0.26
psycopg2-binary==2.9.6 psycopg2==2.9.1
python-augeas==1.1.0 python-augeas==1.1.0
requests==2.31.0 requests==2.26.0
requests-gssapi==1.2.3 requests-gssapi==1.2.3
urwid==2.1.2 urwid==2.1.2
Werkzeug==2.3.6 Werkzeug==2.1.2
zope.component==5.0.1 zope.component==5.0.1
zope.interface==5.4.0 zope.interface==5.4.0

View File

@ -1,50 +0,0 @@
#!/bin/bash
set -eux
SUITE=bullseye
PYTHON_VER=3.9
DOCKER=docker
if command -v podman >/dev/null; then
DOCKER=podman
export BUILDAH_FORMAT=docker
fi
build_image() {
local HOST=$1
local BASE_IMAGE=$2
local IMAGE=ceo-$HOST:$SUITE
if $DOCKER image exists $IMAGE; then
return
fi
run=". .drone/$HOST-setup.sh && IMAGE__setup"
if [ $HOST = generic ]; then
run=". .drone/phosphoric-acid-setup.sh && IMAGE__setup"
fi
$DOCKER build -t $IMAGE -f - . <<EOF
FROM $BASE_IMAGE
WORKDIR /app
COPY .drone .drone
RUN [ "/bin/bash", "-c", "$run" ]
ENTRYPOINT [ "./docker-entrypoint.sh" ]
EOF
}
# Install the Python dependencies into a volume and re-use it for all the containers
if ! $DOCKER volume exists ceo-venv; then
$DOCKER volume create ceo-venv
$DOCKER run \
--rm \
--security-opt label=disable \
-w /app/venv \
-v ceo-venv:/app/venv:z \
-v ./requirements.txt:/tmp/requirements.txt:ro \
-v ./dev-requirements.txt:/tmp/dev-requirements.txt:ro \
python:$PYTHON_VER-slim-$SUITE \
sh -c "apt update && apt install --no-install-recommends -y gcc libkrb5-dev libaugeas0 && python -m venv . && bin/pip install -r /tmp/requirements.txt -r /tmp/dev-requirements.txt"
fi
build_image auth1 debian:$SUITE-slim
build_image coffee python:$PYTHON_VER-slim-$SUITE
# Used by the phosphoric-acid and mail containers
build_image generic python:$PYTHON_VER-slim-$SUITE

View File

@ -1,16 +0,0 @@
#!/bin/bash
set -eux
DOCKER=docker
if command -v podman >/dev/null; then
DOCKER=podman
fi
$DOCKER images --format="{{index .Names 0}}" | \
grep -E "^(localhost/)?ceo-" | \
while read name; do $DOCKER rmi $name; done
if $DOCKER volume exists ceo-venv; then
$DOCKER volume rm ceo-venv
fi

View File

@ -1,4 +0,0 @@
#!/bin/sh
# Install pre-configured git hooks
git config --local core.hooksPath .githooks/

View File

@ -1,18 +0,0 @@
#!/bin/bash
. venv/bin/activate
echo -e "\nLinting Python files with Flake8:\n"
flake8
PASS=$?
echo -e "\nPython linting complete!\n"
if [ "$PASS" -eq 0 ]; then
echo -e "\033[42mCOMMIT SUCCEEDED\033[0m\n"
exit $?
else
echo -e "\033[41mCOMMIT FAILED:\033[0m Your commit contains files that should pass flake8 but do not. Please fix the flake8 errors and try again.\n"
exit 1
fi

View File

@ -17,11 +17,6 @@ def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg):
'Congratulations! Your cloud account has been activated.\n' 'Congratulations! Your cloud account has been activated.\n'
f'You may now login into https://cloud.{base_domain} with your CSC credentials.\n' f'You may now login into https://cloud.{base_domain} with your CSC credentials.\n'
"Make sure to enter 'Members' for the domain (no quotes).\n" "Make sure to enter 'Members' for the domain (no quotes).\n"
'\n'
'Please note that your cloud account will be PERMANENTLY DELETED when\n'
'your CSC membership expires, so make sure to purchase enough membership\n'
'terms in advance. You will receive a warning email one week before your\n'
'cloud account is deleted, so please make sure to check your Junk folder.\n'
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == expected assert result.output == expected

View File

@ -1,6 +1,5 @@
import os import os
import shutil import shutil
import time
from click.testing import CliRunner from click.testing import CliRunner
from mysql.connector import connect from mysql.connector import connect
@ -11,24 +10,14 @@ from ceo.cli import cli
def mysql_attempt_connection(host, username, password): def mysql_attempt_connection(host, username, password):
time.sleep(0.5)
with connect( with connect(
host=host, host=host,
user=username, user=username,
password=password, password=password,
) as con, con.cursor() as cur: ) as con, con.cursor() as cur:
found = False cur.execute("SHOW DATABASES")
# Sometimes, when running the tests locally, I've observed a race condition response = cur.fetchall()
# where another client can't "see" a database right after we create it. assert len(response) == 2
# I only observed this after upgrading the containers to bullseye.
for _ in range(4):
cur.execute("SHOW DATABASES")
response = cur.fetchall()
if len(response) == 2:
found = True
break
time.sleep(0.25)
assert found
with pytest.raises(ProgrammingError): with pytest.raises(ProgrammingError):
cur.execute("CREATE DATABASE new_db") cur.execute("CREATE DATABASE new_db")
@ -45,7 +34,7 @@ def test_mysql(cli_setup, cfg, ldap_user):
# create database for user # create database for user
result = runner.invoke(cli, ['mysql', 'create', username], input='y\n') result = runner.invoke(cli, ['mysql', 'create', username], input='y\n')
#print(result.output) # noqa: E265 print(result.output)
assert result.exit_code == 0 assert result.exit_code == 0
assert os.path.isfile(info_file_path) assert os.path.isfile(info_file_path)

View File

@ -77,27 +77,6 @@ def test_groups(cli_setup, ldap_user):
result = runner.invoke(cli, ['groups', 'delete', 'test_group_1'], input='y\n') result = runner.invoke(cli, ['groups', 'delete', 'test_group_1'], input='y\n')
assert result.exit_code == 0 assert result.exit_code == 0
group_names = [
"touch",
"error",
"happy",
]
runner = CliRunner()
for name in group_names:
result = runner.invoke(cli, [
'groups', 'add', name, '-d', 'searchable group',
], input='y\n')
assert result.exit_code == 0
for name in group_names:
result = runner.invoke(cli, ['groups', 'search', '--count=1', name])
assert result.exit_code == 0
assert result.output.find(name) != -1
for name in group_names:
result = runner.invoke(cli, ['groups', 'delete', name], input='y\n')
assert result.exit_code == 0
def create_group(group_name, desc): def create_group(group_name, desc):
runner = CliRunner() runner = CliRunner()
@ -138,7 +117,8 @@ def test_groups_multiple_members(cli_setup, new_user_gen):
def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user): def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
runner = CliRunner() runner = CliRunner()
# make sure auxiliary groups + mailing lists exist in ceod_test_local.ini # make sure auxiliary groups + mailing lists exist in ceod_test_local.ini
create_group('adm', 'Administrators') create_group('syscom', 'Systems Committee')
create_group('office', 'Office')
create_group('staff', 'Staff') create_group('staff', 'Staff')
runner = CliRunner() runner = CliRunner()
@ -151,7 +131,7 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
"Add user to auxiliary groups... Done\n" "Add user to auxiliary groups... Done\n"
"Subscribe user to auxiliary mailing lists... Done\n" "Subscribe user to auxiliary mailing lists... Done\n"
"Transaction successfully completed.\n" "Transaction successfully completed.\n"
f"Added {ldap_user.uid} to syscom, adm, staff\n" f"Added {ldap_user.uid} to syscom, office, staff\n"
f"Subscribed {ldap_user.uid} to syscom@csclub.internal, syscom-alerts@csclub.internal\n" f"Subscribed {ldap_user.uid} to syscom@csclub.internal, syscom-alerts@csclub.internal\n"
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -167,7 +147,7 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
"Remove user from auxiliary groups... Done\n" "Remove user from auxiliary groups... Done\n"
"Unsubscribe user from auxiliary mailing lists... Done\n" "Unsubscribe user from auxiliary mailing lists... Done\n"
"Transaction successfully completed.\n" "Transaction successfully completed.\n"
f"Removed {ldap_user.uid} from syscom, adm, staff\n" f"Removed {ldap_user.uid} from syscom, office, staff\n"
f"Unsubscribed {ldap_user.uid} from syscom@csclub.internal, syscom-alerts@csclub.internal\n" f"Unsubscribed {ldap_user.uid} from syscom@csclub.internal, syscom-alerts@csclub.internal\n"
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -187,5 +167,6 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Unsubscribed' not in result.output assert 'Unsubscribed' not in result.output
delete_group('adm') delete_group('syscom')
delete_group('office')
delete_group('staff') delete_group('staff')

View File

@ -4,7 +4,6 @@ import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
from click.testing import CliRunner from click.testing import CliRunner
import ldap3
from ceo.cli import cli from ceo.cli import cli
from ceo_common.model import Term from ceo_common.model import Term
@ -26,9 +25,8 @@ def test_members_get(cli_setup, ldap_user):
f"home directory: {ldap_user.home_directory}\n" f"home directory: {ldap_user.home_directory}\n"
f"is a club: {ldap_user.is_club()}\n" f"is a club: {ldap_user.is_club()}\n"
f"is a club rep: {ldap_user.is_club_rep}\n" f"is a club rep: {ldap_user.is_club_rep}\n"
f"forwarding addresses:\n" "forwarding addresses: \n"
f"member terms: {','.join(ldap_user.terms)}\n" f"member terms: {','.join(ldap_user.terms)}\n"
f"groups:\n"
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == expected assert result.output == expected
@ -136,20 +134,19 @@ def test_members_renew(cli_setup, ldap_user, g_admin_ctx):
assert result.output == expected assert result.output == expected
def test_members_pwreset(cli_setup, ldap_and_krb_user): def test_members_pwreset(cli_setup, ldap_user, krb_user):
uid = ldap_and_krb_user.uid
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
cli, ['members', 'pwreset', uid], input='y\n') cli, ['members', 'pwreset', ldap_user.uid], input='y\n')
expected_pat = re.compile(( expected_pat = re.compile((
f"^Are you sure you want to reset {uid}'s password\\? \\[y/N\\]: y\n" f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n"
"New password: \\S+\n$" "New password: \\S+\n$"
), re.MULTILINE) ), re.MULTILINE)
assert result.exit_code == 0 assert result.exit_code == 0
assert expected_pat.match(result.output) is not None assert expected_pat.match(result.output) is not None
def test_members_expire(cli_setup, app_process, ldap_user): def test_members_expire(cli_setup, app_process, ldap_user, syscom_group):
runner = CliRunner() runner = CliRunner()
try: try:
@ -175,17 +172,12 @@ def test_members_expire(cli_setup, app_process, ldap_user):
restore_datetime_in_app_process(app_process) restore_datetime_in_app_process(app_process)
def test_members_remindexpire(cfg, cli_setup, app_process, ldap_conn, ldap_user): def test_members_remindexpire(cli_setup, app_process, ldap_user):
runner = CliRunner() runner = CliRunner()
term = Term(ldap_user.terms[0]) term = Term(ldap_user.terms[0])
test_date = (term + 1).to_datetime()
# Add a term to ctdalek so that he doesn't show up in the results
base_dn = cfg.get('ldap_users_base')
ldap_conn.modify(
f'uid=ctdalek,{base_dn}',
{'term': [(ldap3.MODIFY_ADD, [str(term + 1)])]})
try: try:
test_date = (term + 1).to_datetime()
set_datetime_in_app_process(app_process, test_date) set_datetime_in_app_process(app_process, test_date)
result = runner.invoke(cli, ['members', 'remindexpire', '--dry-run']) result = runner.invoke(cli, ['members', 'remindexpire', '--dry-run'])
assert result.exit_code == 0 assert result.exit_code == 0
@ -208,7 +200,3 @@ def test_members_remindexpire(cfg, cli_setup, app_process, ldap_conn, ldap_user)
assert result.output == "No members are pending expiration.\n" assert result.output == "No members are pending expiration.\n"
finally: finally:
restore_datetime_in_app_process(app_process) restore_datetime_in_app_process(app_process)
ldap_conn.modify(
f'uid=ctdalek,{base_dn}',
{'term': [(ldap3.MODIFY_DELETE, [str(term + 1)])]})

View File

@ -39,29 +39,22 @@ def test_positions(cli_setup, g_admin_ctx):
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == ''' assert result.output == '''
The positions will be updated: The positions will be updated:
president: test_0 president: test_0
vice-president: test_1 vice-president: test_1
sysadmin: test_2 sysadmin: test_2
secretary: test_3 secretary: test_3
webmaster: test_4 webmaster: test_4
treasurer: treasurer:
cro: cro:
offsck: librarian:
ext-affairs-lead: imapd:
marketing-lead: offsck:
design-lead:
events-lead:
reps-lead:
mods-lead:
photography-lead:
codey-bot-lead:
other:
Do you want to continue? [y/N]: y Do you want to continue? [y/N]: y
Update positions in LDAP... Done Update positions in LDAP... Done
Update executive group in LDAP... Done Update executive group in LDAP... Done
Subscribe to mailing lists... Done Subscribe to mailing lists... Done
Transaction successfully completed. Transaction successfully completed.
'''[1:] '''[1:] # noqa: W291
result = runner.invoke(cli, ['positions', 'get']) result = runner.invoke(cli, ['positions', 'get'])
assert result.exit_code == 0 assert result.exit_code == 0
@ -78,78 +71,3 @@ webmaster: test_4
for user in users: for user in users:
user.remove_from_ldap() user.remove_from_ldap()
group.remove_from_ldap() group.remove_from_ldap()
def test_positions_multiple_users(cli_setup, g_admin_ctx):
runner = CliRunner()
# Setup test data
users = []
with g_admin_ctx():
for i in range(5):
user = User(
uid=f'test_{i}',
cn=f'Test {i}',
given_name='Test',
sn=str(i),
program='Math',
terms=['w2023'],
)
user.add_to_ldap()
users.append(user)
group = Group(
cn='exec',
description='Test Group',
gid_number=10500,
)
group.add_to_ldap()
result = runner.invoke(cli, [
'positions', 'set',
'--president', 'test_0',
'--vice-president', 'test_1,test_2',
'--sysadmin', 'test_2',
'--secretary', 'test_3, test_4, test_2',
], input='y\n')
assert result.exit_code == 0
assert result.output == '''
The positions will be updated:
president: test_0
vice-president: test_1, test_2
sysadmin: test_2
secretary: test_3, test_4, test_2
treasurer:
cro:
webmaster:
offsck:
ext-affairs-lead:
marketing-lead:
design-lead:
events-lead:
reps-lead:
mods-lead:
photography-lead:
codey-bot-lead:
other:
Do you want to continue? [y/N]: y
Update positions in LDAP... Done
Update executive group in LDAP... Done
Subscribe to mailing lists... Done
Transaction successfully completed.
'''[1:]
result = runner.invoke(cli, ['positions', 'get'])
assert result.exit_code == 0
assert result.output == '''
president: test_0
secretary: test_2, test_3, test_4
sysadmin: test_2
vice-president: test_1, test_2
'''[1:]
# Cleanup test data
with g_admin_ctx():
for user in users:
user.remove_from_ldap()
group.remove_from_ldap()

View File

@ -5,7 +5,6 @@ from ceo_common.model import Term
from tests.utils import ( from tests.utils import (
create_php_file_for_club, create_php_file_for_club,
reset_disable_club_conf, reset_disable_club_conf,
create_website_config_for_club,
set_datetime_in_app_process, set_datetime_in_app_process,
restore_datetime_in_app_process, restore_datetime_in_app_process,
) )
@ -16,12 +15,10 @@ def test_disable_club_sites(
new_club_gen, new_user_gen, g_admin_ctx, ldap_srv_session, new_club_gen, new_user_gen, g_admin_ctx, ldap_srv_session,
): ):
runner = CliRunner() runner = CliRunner()
sites_available_dir = webhosting_srv.sites_available_dir
term = Term.current() term = Term.current()
clubs_home = cfg.get('clubs_home') clubs_home = cfg.get('clubs_home')
with new_club_gen() as group, new_user_gen() as user: with new_club_gen() as group, new_user_gen() as user:
create_php_file_for_club(clubs_home, group.cn) create_php_file_for_club(clubs_home, group.cn)
create_website_config_for_club(sites_available_dir, group.cn)
user.add_non_member_terms([str(Term.current())]) user.add_non_member_terms([str(Term.current())])
group.add_member(user.uid) group.add_member(user.uid)

View File

@ -1,19 +0,0 @@
import ceo_common.utils as utils
def test_validate_username():
assert utils.validate_username('') == utils.UsernameValidationResult(False, 'Username must not be empty')
assert utils.validate_username('-failure') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35 - joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35 -joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35- joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35joe-') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35$joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35-joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username(' 35joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35 joe') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('35joe ') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('joe!') == utils.UsernameValidationResult(False, 'Username is invalid')
assert utils.validate_username('e45jong') == utils.UsernameValidationResult(True)
assert utils.validate_username('joe-35') == utils.UsernameValidationResult(True)
assert utils.validate_username('joe35-') == utils.UsernameValidationResult(True)

View File

@ -14,10 +14,8 @@ port = 9987
[positions] [positions]
required = president,vice-president,sysadmin required = president,vice-president,sysadmin
available = president,vice-president,sysadmin,treasurer, available = president,vice-president,treasurer,secretary,
secretary,cro,webmaster,offsck,ext-affairs-lead, sysadmin,cro,librarian,imapd,webmaster,offsck
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql] [mysql]
host = coffee host = coffee

View File

@ -5,8 +5,8 @@ from mysql.connector import connect
from mysql.connector.errors import ProgrammingError from mysql.connector.errors import ProgrammingError
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user): def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_and_krb_user.uid uid = ldap_user.uid
with g_admin_ctx(): with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some', user = User(uid='someone_else', cn='Some Name', given_name='Some',
@ -72,8 +72,8 @@ def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
user.remove_from_ldap() user.remove_from_ldap()
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_and_krb_user): def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_and_krb_user.uid uid = ldap_user.uid
with g_admin_ctx(): with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some', user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -4,8 +4,8 @@ from ceod.model import User
from psycopg2 import connect, OperationalError, ProgrammingError from psycopg2 import connect, OperationalError, ProgrammingError
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user): def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_and_krb_user.uid uid = ldap_user.uid
with g_admin_ctx(): with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some', user = User(uid='someone_else', cn='Some Name', given_name='Some',
@ -74,8 +74,8 @@ def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
user.remove_from_ldap() user.remove_from_ldap()
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_and_krb_user): def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_and_krb_user.uid uid = ldap_user.uid
with g_admin_ctx(): with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some', user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -2,7 +2,6 @@ import ldap3
import pytest import pytest
from ceod.model import Group from ceod.model import Group
from tests.conftest_ceod_api import expect_successful_txn
def test_api_group_not_found(client): def test_api_group_not_found(client):
@ -10,28 +9,20 @@ def test_api_group_not_found(client):
assert status == 404 assert status == 404
def create_group(client, cn: str, description: str): @pytest.fixture(scope='module')
def create_group_resp(client):
status, data = client.post('/api/groups', json={ status, data = client.post('/api/groups', json={
'cn': cn, 'cn': 'test_group1',
'description': description, 'description': 'Test Group One',
}) })
assert status == 200 assert status == 200
assert data[-1]['status'] == 'completed' assert data[-1]['status'] == 'completed'
return status, data yield status, data
status, data = client.delete('/api/groups/test_group1')
def delete_group(client, cn: str):
status, data = client.delete(f'/api/groups/{cn}')
assert status == 200 assert status == 200
assert data[-1]['status'] == 'completed' assert data[-1]['status'] == 'completed'
@pytest.fixture(scope='module')
def create_group_resp(client):
yield create_group(client, 'test_group1', 'Test Group One')
delete_group(client, 'test_group1')
@pytest.fixture(scope='module') @pytest.fixture(scope='module')
def create_group_result(create_group_resp): def create_group_result(create_group_resp):
# convenience method # convenience method
@ -120,47 +111,6 @@ def test_api_add_member_to_group(client, create_group_result, ldap_user):
assert data['members'] == [] assert data['members'] == []
@pytest.fixture(scope='module')
def create_random_names():
# generated with https://www.randomlists.com/random-words
random_names = [
"intelligent",
"skin",
"shivering",
]
yield random_names
@pytest.fixture(scope='module')
def create_searchable_groups(client, create_random_names):
random_names = create_random_names
for name in random_names:
create_group(client, name, 'Groups with distinct names for testing searching')
yield random_names
def test_api_group_search(client, create_searchable_groups):
cns = create_searchable_groups
# pairs of cn indices as well as amount of results that should be returned
random_numbers = [
(0, 68),
(1, 54),
(2, 97),
]
for tup in random_numbers:
cn = cns[tup[0]]
status, data = client.get(f'/api/groups/search/{cn}/{tup[1]}')
assert status == 200
assert len(data) == tup[1]
assert data[0] == cn
for cn in cns:
status, data = client.delete(f'/api/groups/{cn}')
assert status == 200
assert data[-1]['status'] == 'completed'
def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx): def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
# Make sure that syscom has auxiliary mailing lists and groups # Make sure that syscom has auxiliary mailing lists and groups
# defined in ceod_test_local.ini. # defined in ceod_test_local.ini.
@ -175,8 +125,7 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
group_names = ['syscom'] + aux_groups group_names = ['syscom'] + aux_groups
groups = [] groups = []
with g_admin_ctx(): with g_admin_ctx():
# the syscom group should already exist, since we need it for auth for group_name in group_names:
for group_name in aux_groups:
group = Group( group = Group(
cn=group_name, cn=group_name,
gid_number=min_uid, gid_number=min_uid,
@ -212,29 +161,4 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
with g_admin_ctx(): with g_admin_ctx():
for group in groups: for group in groups:
if group.cn != 'syscom': group.remove_from_ldap()
group.remove_from_ldap()
@pytest.mark.parametrize('desc,expect_success', [
('in_syscom', True),
('is_offsck', True),
(None, False),
])
def test_api_add_or_remove_member_from_office(desc, expect_success, cfg, client, ldap_and_krb_user, new_user, g_admin_ctx):
uid = ldap_and_krb_user.uid
if desc == 'in_syscom':
uid = 'ctdalek'
elif desc == 'is_offsck':
with g_admin_ctx():
ldap_and_krb_user.set_positions(['offsck'])
def handle_resp(status_and_data):
if expect_success:
expect_successful_txn(status_and_data)
else:
status = status_and_data[0]
assert status == 403
handle_resp(client.post(f'/api/groups/office/members/{new_user.uid}', principal=uid))
handle_resp(client.delete(f'/api/groups/office/members/{new_user.uid}', principal=uid))

View File

@ -72,6 +72,7 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
"mail_local_addresses": ["test1@csclub.internal"], "mail_local_addresses": ["test1@csclub.internal"],
"forwarding_addresses": ['test1@uwaterloo.internal'], "forwarding_addresses": ['test1@uwaterloo.internal'],
"password": "krb5", "password": "krb5",
"shadowExpire": None,
}}, }},
] ]
assert data == expected assert data == expected
@ -83,25 +84,6 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
mock_mail_server.messages.clear() mock_mail_server.messages.clear()
def test_api_create_user_with_num_terms(client):
status, data = client.post('/api/members', json={
'uid': 'test2',
'cn': 'Test Two',
'given_name': 'Test',
'sn': 'Two',
'program': 'Math',
'terms': 2,
'forwarding_addresses': ['test2@uwaterloo.internal'],
})
assert status == 200
assert data[-1]['status'] == 'completed'
current_term = Term.current()
assert data[-1]['result']['terms'] == [str(current_term), str(current_term + 1)]
status, data = client.delete('/api/members/test2')
assert status == 200
assert data[-1]['status'] == 'completed'
def test_api_next_uid(cfg, client, create_user_result): def test_api_next_uid(cfg, client, create_user_result):
min_uid = cfg.get('members_min_id') min_uid = cfg.get('members_min_id')
_, data = client.post('/api/members', json={ _, data = client.post('/api/members', json={
@ -111,7 +93,6 @@ def test_api_next_uid(cfg, client, create_user_result):
'sn': 'Two', 'sn': 'Two',
'program': 'Math', 'program': 'Math',
'terms': ['s2021'], 'terms': ['s2021'],
'forwarding_addresses': ['test2@uwaterloo.internal']
}) })
assert data[-1]['status'] == 'completed' assert data[-1]['status'] == 'completed'
result = data[-1]['result'] result = data[-1]['result']
@ -122,60 +103,12 @@ def test_api_next_uid(cfg, client, create_user_result):
client.delete('/api/members/test2') client.delete('/api/members/test2')
def test_api_create_user_without_forwarding_addresses(cfg, client):
status, data = client.post('/api/members', json={
'uid': 'test3',
'cn': 'Test Three',
'given_name': 'Test',
'sn': 'Three',
'program': 'Math',
'terms': ['s2023'],
})
assert status == 400
assert data['error'] == "BadRequest: Attribute 'forwarding_addresses' is missing or empty"
def test_api_create_user_without_valid_username(cfg, client):
status, data = client.post('/api/members', json={
'uid': '4_test',
'cn': 'Test Four',
'given_name': 'Test',
'sn': 'Four',
'program': 'Math',
'terms': ['w2024'],
'forwarding_addresses': ['test4@uwaterloo.internal'],
})
try:
assert status == 400
assert data['error'] == "BadRequest: Attribute 'uid' is missing or invalid"
finally:
client.delete('/api/members/4_test')
def test_api_create_user_with_valid_username(cfg, client):
status, data = client.post('/api/members', json={
'uid': 'test-4',
'cn': 'Test Four',
'given_name': 'Test',
'sn': 'Four',
'program': 'Math',
'terms': ['w2024'],
'forwarding_addresses': ['test4@uwaterloo.internal'],
})
try:
assert status == 200
assert data[-1]['status'] == 'completed'
finally:
client.delete('/api/members/test-4')
def test_api_get_user(cfg, client, create_user_result): def test_api_get_user(cfg, client, create_user_result):
old_data = create_user_result.copy() old_data = create_user_result.copy()
uid = old_data['uid'] uid = old_data['uid']
del old_data['password'] del old_data['password']
status, data = client.get(f'/api/members/{uid}') status, data = client.get(f'/api/members/{uid}')
del data['groups']
assert status == 200 assert status == 200
assert data == old_data assert data == old_data
@ -270,20 +203,6 @@ def test_api_renew_user(cfg, client, create_user_result, ldap_conn):
ldap_conn.modify(dn, changes) ldap_conn.modify(dn, changes)
def test_api_renew_user_with_num_terms(client, ldap_user):
uid = ldap_user.uid
status, data = client.post(f'/api/members/{uid}/renew', json={'terms': 2})
assert status == 200
_, data = client.get(f'/api/members/{uid}')
current_term = Term.current()
assert data['terms'] == [str(current_term), str(current_term + 1), str(current_term + 2)]
status, data = client.post(f'/api/members/{uid}/renew', json={'non_member_terms': 2})
assert status == 200
_, data = client.get(f'/api/members/{uid}')
assert data['non_member_terms'] == [str(current_term), str(current_term + 1)]
def test_api_reset_password(client, create_user_result): def test_api_reset_password(client, create_user_result):
uid = create_user_result['uid'] uid = create_user_result['uid']
with patch.object(ceod.utils, 'gen_password') as gen_password_mock: with patch.object(ceod.utils, 'gen_password') as gen_password_mock:
@ -300,8 +219,8 @@ def test_api_reset_password(client, create_user_result):
def test_authz_check(client, create_user_result): def test_authz_check(client, create_user_result):
# non-staff members may not create users # non-staff members may not create users
status, data = client.post('/api/members', json={ status, data = client.post('/api/members', json={
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One', 'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
'terms': ['s2021'], 'forwarding_addresses': ['test1@uwaterloo.internal'] 'sn': 'One', 'terms': ['s2021'],
}, principal='regular1') }, principal='regular1')
assert status == 403 assert status == 403
@ -311,19 +230,18 @@ def test_authz_check(client, create_user_result):
del old_data['password'] del old_data['password']
del old_data['forwarding_addresses'] del old_data['forwarding_addresses']
_, data = client.get(f'/api/members/{uid}', principal='regular1') _, data = client.get(f'/api/members/{uid}', principal='regular1')
del data['groups']
assert data == old_data assert data == old_data
# If we're syscom but we don't pass credentials, the request should fail # If we're syscom but we don't pass credentials, the request should fail
_, data = client.post('/api/members', json={ _, data = client.post('/api/members', json={
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One', 'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
'terms': ['s2021'], 'forwarding_addresses': ['test1@uwaterloo.internal'] 'sn': 'One', 'terms': ['s2021'],
}, principal='ctdalek', delegate=False) }, principal='ctdalek', delegate=False)
assert data[-1]['status'] == 'aborted' assert data[-1]['status'] == 'aborted'
@pytest.mark.parametrize('term_attr', ['terms', 'non_member_terms']) @pytest.mark.parametrize('term_attr', ['terms', 'non_member_terms'])
def test_expire(client, new_user, term_attr, ldap_conn): def test_expire(client, new_user, term_attr, syscom_group, ldap_conn):
assert new_user.shadowExpire is None assert new_user.shadowExpire is None
current_term = Term.current() current_term = Term.current()
start_of_current_term = current_term.to_datetime() start_of_current_term = current_term.to_datetime()
@ -363,18 +281,17 @@ def test_expire(client, new_user, term_attr, ldap_conn):
status, data = client.post('/api/members/expire?dry_run=yes') status, data = client.post('/api/members/expire?dry_run=yes')
assert status == 200 assert status == 200
print(data)
assert (data == [uid]) == should_expire assert (data == [uid]) == should_expire
_, user = client.get(f'/api/members/{uid}') _, user = client.get(f'/api/members/{uid}')
assert 'shadow_expire' not in user assert user['shadowExpire'] is None
status, data = client.post('/api/members/expire') status, data = client.post('/api/members/expire')
assert status == 200 assert status == 200
assert (data == [uid]) == should_expire assert (data == [uid]) == should_expire
_, user = client.get(f'/api/members/{uid}') _, user = client.get(f'/api/members/{uid}')
assert (user.get('shadow_expire') == 1) == should_expire assert (user['shadowExpire'] is not None) == should_expire
if not should_expire: if not should_expire:
continue continue
@ -384,18 +301,20 @@ def test_expire(client, new_user, term_attr, ldap_conn):
assert status == 200 assert status == 200
_, user = client.get(f'/api/members/{uid}') _, user = client.get(f'/api/members/{uid}')
assert 'shadow_expire' not in user assert user['shadowExpire'] is None
reset_terms() reset_terms()
@pytest.mark.parametrize('in_syscom', [True, False]) @pytest.mark.parametrize('in_syscom', [True, False])
def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom): def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_conn, in_syscom):
uid = new_user.uid uid = new_user.uid
start_of_current_term = Term.current().to_datetime() start_of_current_term = Term.current().to_datetime()
if in_syscom: if in_syscom:
group_dn = new_user.ldap_srv.group_cn_to_dn('syscom') group_dn = new_user.ldap_srv.group_cn_to_dn('syscom')
user_dn = new_user.ldap_srv.uid_to_dn(uid) user_dn = new_user.ldap_srv.uid_to_dn(uid)
changes = {'uniqueMember': [(ldap3.MODIFY_ADD, [user_dn])]} changes = {
'uniqueMember': [(ldap3.MODIFY_ADD, [user_dn])]
}
ldap_conn.modify(group_dn, changes) ldap_conn.modify(group_dn, changes)
with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock:
datetime_mock.return_value = start_of_current_term + datetime.timedelta(days=160) datetime_mock.return_value = start_of_current_term + datetime.timedelta(days=160)
@ -405,12 +324,9 @@ def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom):
assert data == [] assert data == []
else: else:
assert data == [uid] assert data == [uid]
if in_syscom:
changes = {'uniqueMember': [(ldap3.MODIFY_DELETE, [user_dn])]}
ldap_conn.modify(group_dn, changes)
def test_office_member(cfg, client, office_user): def test_office_member(cfg, client):
admin_principal = cfg.get('ldap_admin_principal') admin_principal = cfg.get('ldap_admin_principal')
ccache_file = cfg.get('ldap_admin_principal_ccache') ccache_file = cfg.get('ldap_admin_principal_ccache')
if os.path.isfile(ccache_file): if os.path.isfile(ccache_file):
@ -451,17 +367,11 @@ def test_office_member(cfg, client, office_user):
assert status == 200 assert status == 200
def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn): def test_membership_renewal_reminder(client, mock_mail_server):
uids = ['test3', 'test4'] uids = ['test3', 'test4']
# fast-forward by one term so that we don't clash with the other users # fast-forward by one term so that we don't clash with the other users
# created by other tests # created by other tests
term = Term.current() + 1 term = Term.current() + 1
# Add some terms to ctdalek so that he doesn't show up in the API calls below
base_dn = cfg.get('ldap_users_base')
ldap_conn.modify(
f'uid=ctdalek,{base_dn}',
{'term': [(ldap3.MODIFY_ADD, [str(term), str(term + 1), str(term + 2)])]})
with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock: with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock:
datetime_mock.return_value = term.to_datetime() datetime_mock.return_value = term.to_datetime()
for uid in uids: for uid in uids:
@ -523,7 +433,3 @@ def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn):
status, _ = client.delete(f'/api/members/{uid}') status, _ = client.delete(f'/api/members/{uid}')
assert status == 200 assert status == 200
mock_mail_server.messages.clear() mock_mail_server.messages.clear()
ldap_conn.modify(
f'uid=ctdalek,{base_dn}',
{'term': [(ldap3.MODIFY_DELETE, [str(term), str(term + 1), str(term + 2)])]})

View File

@ -7,8 +7,8 @@ def test_get_positions(client, ldap_user, g_admin_ctx):
status, data = client.get('/api/positions') status, data = client.get('/api/positions')
assert status == 200 assert status == 200
expected = { expected = {
'president': [ldap_user.uid], 'president': ldap_user.uid,
'treasurer': [ldap_user.uid], 'treasurer': ldap_user.uid,
} }
assert data == expected assert data == expected
@ -20,7 +20,7 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
users = [] users = []
with g_admin_ctx(): with g_admin_ctx():
for uid in ['test1', 'test2', 'test3', 'test4']: for uid in ['test_1', 'test_2', 'test_3', 'test_4']:
user = User(uid=uid, cn='Some Name', given_name='Some', sn='Name', user = User(uid=uid, cn='Some Name', given_name='Some', sn='Name',
terms=['s2021']) terms=['s2021'])
user.add_to_ldap() user.add_to_ldap()
@ -31,23 +31,23 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
try: try:
# missing required position # missing required position
status, _ = client.post('/api/positions', json={ status, _ = client.post('/api/positions', json={
'vice-president': 'test1', 'vice-president': 'test_1',
}) })
assert status == 400 assert status == 400
# non-existent position # non-existent position
status, _ = client.post('/api/positions', json={ status, _ = client.post('/api/positions', json={
'president': 'test1', 'president': 'test_1',
'vice-president': 'test2', 'vice-president': 'test_2',
'sysadmin': 'test3', 'sysadmin': 'test_3',
'no-such-position': 'test3', 'no-such-position': 'test_3',
}) })
assert status == 400 assert status == 400
status, data = client.post('/api/positions', json={ status, data = client.post('/api/positions', json={
'president': 'test1', 'president': 'test_1',
'vice-president': 'test2', 'vice-president': 'test_2',
'sysadmin': 'test3', 'sysadmin': 'test_3',
}) })
assert status == 200 assert status == 200
expected = [ expected = [
@ -55,16 +55,16 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
{"status": "in progress", "operation": "update_exec_group_ldap"}, {"status": "in progress", "operation": "update_exec_group_ldap"},
{"status": "in progress", "operation": "subscribe_to_mailing_lists"}, {"status": "in progress", "operation": "subscribe_to_mailing_lists"},
{"status": "completed", "result": { {"status": "completed", "result": {
"president": ["test1"], "president": "test_1",
"vice-president": ["test2"], "vice-president": "test_2",
"sysadmin": ["test3"], "sysadmin": "test_3",
}}, }},
] ]
assert data == expected assert data == expected
# make sure execs were added to exec group # make sure execs were added to exec group
status, data = client.get('/api/groups/exec') status, data = client.get('/api/groups/exec')
assert status == 200 assert status == 200
expected = ['test1', 'test2', 'test3'] expected = ['test_1', 'test_2', 'test_3']
assert sorted([item['uid'] for item in data['members']]) == expected assert sorted([item['uid'] for item in data['members']]) == expected
# make sure execs were subscribed to mailing lists # make sure execs were subscribed to mailing lists
addresses = [f'{uid}@{base_domain}' for uid in expected] addresses = [f'{uid}@{base_domain}' for uid in expected]
@ -72,55 +72,20 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses
_, data = client.post('/api/positions', json={ _, data = client.post('/api/positions', json={
'president': 'test1', 'president': 'test_1',
'vice-president': 'test2', 'vice-president': 'test_2',
'sysadmin': 'test2', 'sysadmin': 'test_2',
'treasurer': 'test4', 'treasurer': 'test_4',
}) })
assert data[-1]['status'] == 'completed' assert data[-1]['status'] == 'completed'
# make sure old exec was removed from group # make sure old exec was removed from group
expected = ['test1', 'test2', 'test4'] expected = ['test_1', 'test_2', 'test_4']
_, data = client.get('/api/groups/exec') _, data = client.get('/api/groups/exec')
assert sorted([item['uid'] for item in data['members']]) == expected assert sorted([item['uid'] for item in data['members']]) == expected
# make sure old exec was removed from mailing lists # make sure old exec was removed from mailing lists
addresses = [f'{uid}@{base_domain}' for uid in expected] addresses = [f'{uid}@{base_domain}' for uid in expected]
for mailing_list in mailing_lists: for mailing_list in mailing_lists:
assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses
# multiple users per position
status, data = client.post('/api/positions', json={
'president': 'test1',
'vice-president': ['test2', 'test3'],
'sysadmin': 'test2, test3,test4',
})
assert status == 200
assert data[-1] == {'status': 'completed', 'result': {
'president': ['test1'],
'vice-president': ['test2', 'test3'],
'sysadmin': ['test2', 'test3', 'test4'],
}}
# check every single psition
status, data = client.post('/api/positions', json={
'president': 'test1',
'vice-president': 'test1',
'treasurer': 'test1',
'secretary': 'test1',
'sysadmin': 'test1',
'cro': 'test1',
'webmaster': 'test1',
'offsck': 'test1',
'ext-affairs-lead': 'test1',
'marketing-lead': 'test1',
'design-lead': 'test1',
'events-lead': 'test1',
'reps-lead': 'test1',
'mods-lead': 'test1',
'photography-lead': 'test1',
'codey-bot-lead': 'test1',
'other': 'test1',
})
assert status == 200
assert data[-1]['status'] == 'completed'
finally: finally:
with g_admin_ctx(): with g_admin_ctx():
for user in users: for user in users:

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