Compare commits
68 Commits
Author | SHA1 | Date |
---|---|---|
Max Erenberg | cf42e49ae6 | |
Max Erenberg | bf2afd1195 | |
Max Erenberg | 5f8de94393 | |
Max Erenberg | 2164ceddf0 | |
Max Erenberg | 7716f7bd10 | |
Nathan Chung | 32cb22665a | |
Nathan Chung | 56a59186e0 | |
Nathan Chung | 5ecec2b54c | |
Nathan Chung | f584a89cec | |
Nathan Chung | 7d9ec99f8f | |
Nathan Chung | 28adf6e13d | |
Max Erenberg | 9c51ad3a01 | |
Nathan Chung | bf7f1c7724 | |
Leon Zhang | 194b5ec4a6 | |
Nathan Chung | c83bbe2563 | |
Max Erenberg | 32709ad401 | |
Leon Zhang | 3780662ba4 | |
Nathan Chung | b1dac8ce07 | |
Nathan Chung | c5edf5ea48 | |
Max Erenberg | a4a4ef089c | |
Max Erenberg | bd1da799c6 | |
Leon Zhang | 25994af312 | |
Ohm Patel | de23296413 | |
Ohm Patel | f06ccdc3f9 | |
Max Erenberg | 5332259731 | |
Max Erenberg | 392ec153d0 | |
Max Erenberg | 36bf340385 | |
Max Erenberg | 7e851daa8f | |
Max Erenberg | e0ed4fa23a | |
Max Erenberg | 6786c8e44e | |
Max Erenberg | 337c05c511 | |
Max Erenberg | 65688c72da | |
Justin Chung | 968f0815c7 | |
Daniel Sun | 010937ea17 | |
Max Erenberg | 234ab62f27 | |
Max Erenberg | f9bda2f724 | |
Max Erenberg | 239b992107 | |
Max Erenberg | 754731ba5f | |
Max Erenberg | b33339817f | |
Max Erenberg | 6dccd8b659 | |
Max Erenberg | 3f58d1aff5 | |
Justin Chung | 5e8f1b5ba5 | |
Max Erenberg | f84965c8e1 | |
Max Erenberg | 4394c4e277 | |
Jonathan Leung | b507c56136 | |
Max Erenberg | c0c9736593 | |
Max Erenberg | 1e452d10ce | |
Max Erenberg | 6a1fa81b82 | |
Max Erenberg | 6df1f4d459 | |
Edwin | 2cf9e25b59 | |
Edwin | 9ff3d850c9 | |
Edwin | b4a1373559 | |
Raymond Li | dceb5d6572 | |
Jonathan Leung | c30ca54752 | |
Nathan Chung | 3b7c89c925 | |
Max Erenberg | a2324090f3 | |
Max Erenberg | f66f7c8f5a | |
Max Erenberg | 3e5b829085 | |
Rio Liu | 57ba72ef26 | |
Max Erenberg | 779e35a08e | |
Raymond Li | 3cc9b011c3 | |
Max Erenberg | 2739c45aff | |
Max Erenberg | 651f4fb702 | |
Max Erenberg | 953bee549e | |
Max Erenberg | 0334e7e667 | |
Max Erenberg | 8decd3bc30 | |
Max Erenberg | 8ad8271db1 | |
Raymond Li | 4ebb9bb0a8 |
18
.drone.yml
18
.drone.yml
|
@ -5,34 +5,34 @@ name: default
|
|||
steps:
|
||||
# use the step name to mock out the gethostname() call in our tests
|
||||
- name: phosphoric-acid
|
||||
image: python:3.7-buster
|
||||
image: python:3.9-slim-bullseye
|
||||
# unfortunately we have to do everything in one step because there's no
|
||||
# way to share system packages between steps
|
||||
commands:
|
||||
# install dependencies
|
||||
- apt update && apt install -y libkrb5-dev libpq-dev python3-dev libaugeas0
|
||||
- apt update
|
||||
- apt install --no-install-recommends -y gcc libkrb5-dev libaugeas0
|
||||
- python3 -m venv venv
|
||||
- . venv/bin/activate
|
||||
- pip install -r dev-requirements.txt
|
||||
- pip install -r requirements.txt
|
||||
- venv/bin/pip install -r dev-requirements.txt -r requirements.txt
|
||||
|
||||
# lint
|
||||
- flake8
|
||||
|
||||
# unit + integration tests
|
||||
- .drone/phosphoric-acid-setup.sh
|
||||
- bash -c ". .drone/phosphoric-acid-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- pytest -v
|
||||
|
||||
services:
|
||||
- name: auth1
|
||||
image: debian:buster
|
||||
image: debian:bullseye-slim
|
||||
commands:
|
||||
- .drone/auth1-setup.sh
|
||||
- bash -c ". .drone/auth1-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- sleep infinity
|
||||
- name: coffee
|
||||
image: debian:buster
|
||||
image: debian:bullseye-slim
|
||||
commands:
|
||||
- .drone/coffee-setup.sh
|
||||
- bash -c ". .drone/coffee-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- sleep infinity
|
||||
|
||||
trigger:
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
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
|
|
@ -4,103 +4,128 @@ set -ex
|
|||
|
||||
. .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
|
||||
ulimit -n 1024
|
||||
|
||||
# LDAP
|
||||
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
|
||||
# `service slapd stop` doesn't seem to work
|
||||
killall slapd || true
|
||||
service nslcd stop || true
|
||||
rm -rf /etc/ldap/slapd.d
|
||||
rm /var/lib/ldap/*
|
||||
cp /usr/share/slapd/DB_CONFIG /var/lib/ldap/DB_CONFIG
|
||||
cp .drone/slapd.conf /etc/ldap/slapd.conf
|
||||
cp .drone/ldap.conf /etc/ldap/ldap.conf
|
||||
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
|
||||
cp .drone/rfc2307bis.schema /etc/ldap/schema/
|
||||
cp .drone/csc.schema /etc/ldap/schema/
|
||||
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
|
||||
sleep 0.5 && service slapd start
|
||||
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
|
||||
echo 'map group member uniqueMember' >> /etc/nslcd.conf
|
||||
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
|
||||
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
|
||||
cp .drone/nsswitch.conf /etc/nsswitch.conf
|
||||
service nslcd start
|
||||
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
|
||||
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
|
||||
CONTAINER__fix_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
|
||||
}
|
||||
|
||||
# KERBEROS
|
||||
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
|
||||
service krb5-admin-server stop || true
|
||||
service krb5-kdc stop || true
|
||||
service saslauthd stop || true
|
||||
cp .drone/krb5.conf /etc/krb5.conf
|
||||
cp .drone/kdc.conf /etc/krb5kdc.conf
|
||||
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
|
||||
rm -f /var/lib/krb5kdc/*
|
||||
echo -e 'krb5\nkrb5' | krb5_newrealm
|
||||
service krb5-kdc start
|
||||
service krb5-admin-server start
|
||||
rm -f /etc/krb5.keytab
|
||||
cat <<EOF | kadmin.local
|
||||
IMAGE__setup_ldap() {
|
||||
# In the "slim" Docker images, /usr/share/doc/* is excluded by default
|
||||
echo 'path-include /usr/share/doc/sudo-ldap/schema.OpenLDAP' > /etc/dpkg/dpkg.cfg.d/zz-ceo
|
||||
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
|
||||
# `service slapd stop` doesn't seem to work
|
||||
killall slapd || true
|
||||
service nslcd stop || true
|
||||
rm -rf /etc/ldap/slapd.d
|
||||
rm /var/lib/ldap/*
|
||||
cp .drone/slapd.conf /etc/ldap/slapd.conf
|
||||
cp .drone/ldap.conf /etc/ldap/ldap.conf
|
||||
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
|
||||
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
|
||||
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
|
||||
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
|
||||
addprinc -pw krb5 sysadmin/admin
|
||||
addprinc -pw krb5 ctdalek
|
||||
addprinc -pw krb5 exec1
|
||||
addprinc -pw krb5 regular1
|
||||
addprinc -pw krb5 office1
|
||||
addprinc -randkey ceod/admin
|
||||
addprinc -randkey host/auth1.csclub.internal
|
||||
addprinc -randkey ldap/auth1.csclub.internal
|
||||
ktadd host/auth1.csclub.internal
|
||||
ktadd ldap/auth1.csclub.internal
|
||||
EOF
|
||||
groupadd keytab || true
|
||||
chgrp keytab /etc/krb5.keytab
|
||||
chmod 640 /etc/krb5.keytab
|
||||
usermod -a -G keytab openldap
|
||||
usermod -a -G sasl openldap
|
||||
cat <<EOF > /usr/lib/sasl2/slapd.conf
|
||||
# Add all of the people defined in data.ldif
|
||||
for princ in ctdalek exec1 regular1 office1; do
|
||||
echo "addprinc -pw krb5 $princ" | kadmin.local
|
||||
done
|
||||
groupadd keytab || true
|
||||
chgrp keytab /etc/krb5.keytab
|
||||
chmod 640 /etc/krb5.keytab
|
||||
usermod -a -G keytab openldap
|
||||
usermod -a -G sasl openldap
|
||||
cat <<EOF > /usr/lib/sasl2/slapd.conf
|
||||
mech_list: plain login gssapi external
|
||||
pwcheck_method: saslauthd
|
||||
EOF
|
||||
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
|
||||
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
|
||||
service saslauthd start
|
||||
while true; do
|
||||
killall slapd
|
||||
sleep 1
|
||||
if service slapd start; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
|
||||
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
|
||||
}
|
||||
|
||||
# sync with phosphoric-acid
|
||||
nc -l 0.0.0.0 9000 &
|
||||
if [ -z "$CI" ]; then
|
||||
# sync with coffee
|
||||
nc -l 0.0.0.0 9001 &
|
||||
# sync with mail
|
||||
nc -l 0.0.0.0 9002 &
|
||||
fi
|
||||
IMAGE__setup() {
|
||||
# slapd needs /etc/hosts to be setup properly
|
||||
CONTAINER__fix_resolv_conf
|
||||
CONTAINER__fix_hosts
|
||||
|
||||
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
|
||||
service krb5-admin-server start
|
||||
service krb5-kdc start
|
||||
service saslauthd start
|
||||
service nslcd start
|
||||
# Let other containers know that we're ready
|
||||
nc -l -k 0.0.0.0 9000 &
|
||||
}
|
||||
|
|
|
@ -4,25 +4,28 @@ set -ex
|
|||
|
||||
. .drone/common.sh
|
||||
|
||||
# set FQDN in /etc/hosts
|
||||
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
|
||||
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
|
||||
CONTAINER__fix_hosts() {
|
||||
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
|
||||
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
|
||||
}
|
||||
|
||||
apt install --no-install-recommends -y default-mysql-server postgresql
|
||||
IMAGE__setup() {
|
||||
IMAGE__ceod_setup
|
||||
apt install --no-install-recommends -y default-mysql-server postgresql
|
||||
|
||||
# MYSQL
|
||||
service mysql stop
|
||||
sed -E -i 's/^(bind-address[[:space:]]+= 127.0.0.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
|
||||
service mysql start
|
||||
cat <<EOF | mysql
|
||||
# MYSQL
|
||||
service mariadb stop
|
||||
sed -E -i 's/^(bind-address[[:space:]]+= 127\.0\.0\.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
|
||||
service mariadb start
|
||||
cat <<EOF | mysql
|
||||
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
|
||||
EOF
|
||||
|
||||
# POSTGRESQL
|
||||
service postgresql stop
|
||||
POSTGRES_DIR=/etc/postgresql/11/main
|
||||
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
|
||||
# POSTGRESQL
|
||||
service postgresql stop
|
||||
local POSTGRES_DIR=/etc/postgresql/*/main
|
||||
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
local all postgres peer
|
||||
host all postgres localhost md5
|
||||
|
@ -36,19 +39,29 @@ local sameuser all peer
|
|||
host sameuser all 0.0.0.0/0 md5
|
||||
host sameuser all ::/0 md5
|
||||
EOF
|
||||
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
|
||||
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
|
||||
service postgresql start
|
||||
su -c "
|
||||
cat <<EOF | psql
|
||||
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
|
||||
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
|
||||
service postgresql start
|
||||
su -c "
|
||||
cat <<EOF | psql
|
||||
ALTER USER postgres WITH PASSWORD 'postgres';
|
||||
REVOKE ALL ON SCHEMA public FROM public;
|
||||
GRANT ALL ON SCHEMA public TO postgres;
|
||||
EOF" postgres
|
||||
|
||||
if [ -z "$CI" ]; then
|
||||
auth_setup coffee
|
||||
fi
|
||||
service mariadb stop || true
|
||||
service postgresql stop || true
|
||||
}
|
||||
|
||||
# sync with phosphoric-acid
|
||||
nc -l 0.0.0.0 9000 &
|
||||
CONTAINER__setup() {
|
||||
CONTAINER__fix_resolv_conf
|
||||
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 &
|
||||
}
|
||||
|
|
173
.drone/common.sh
173
.drone/common.sh
|
@ -1,75 +1,14 @@
|
|||
# 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
|
||||
apt update
|
||||
apt install -y netcat-openbsd
|
||||
if [ "$(hostname)" != auth1 ]; then
|
||||
# ceod uses Augeas, which is not installed by default in the Python
|
||||
# Docker container
|
||||
apt install -y libaugeas0
|
||||
fi
|
||||
|
||||
get_ip_addr() {
|
||||
getent hosts $1 | cut -d' ' -f1
|
||||
}
|
||||
|
||||
add_fqdn_to_hosts() {
|
||||
ip_addr=$1
|
||||
hostname=$2
|
||||
sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts
|
||||
cp /tmp/hosts /etc/hosts
|
||||
rm /tmp/hosts
|
||||
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
|
||||
}
|
||||
|
||||
sync_with() {
|
||||
host=$1
|
||||
port=9000
|
||||
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)
|
||||
for i in {1..240}; do
|
||||
if nc -vz $host $port ; then
|
||||
synced=true
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
test $synced = true
|
||||
}
|
||||
|
||||
auth_setup() {
|
||||
hostname=$1
|
||||
# The IMAGE__ functions should be called when building the image.
|
||||
# The CONTAINER__ functions should be called when running an instance of the
|
||||
# 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
|
||||
|
@ -80,28 +19,98 @@ auth_setup() {
|
|||
# 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
|
||||
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
|
||||
# Docker container
|
||||
apt install -y libaugeas0
|
||||
}
|
||||
|
||||
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
|
||||
ktadd host/$hostname.csclub.internal
|
||||
addprinc -randkey ceod/$hostname.csclub.internal
|
||||
ktadd host/$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
|
||||
}
|
||||
|
||||
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() {
|
||||
# There appears to be a bug in newer versions of Podman where using both
|
||||
# --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() {
|
||||
local ip_addr=$1
|
||||
local hostname=$2
|
||||
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
|
||||
rm /tmp/hosts
|
||||
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
|
||||
}
|
||||
|
||||
sync_with() {
|
||||
local host=$1
|
||||
local port=9000
|
||||
local synced=false
|
||||
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
|
||||
for i in {1..240}; do
|
||||
if nc -vz $host $port ; then
|
||||
synced=true
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
test $synced = true
|
||||
}
|
||||
|
|
|
@ -186,3 +186,28 @@ objectClass: group
|
|||
objectClass: posixGroup
|
||||
cn: office1
|
||||
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
|
||||
|
|
|
@ -4,20 +4,27 @@ set -ex
|
|||
|
||||
. .drone/common.sh
|
||||
|
||||
# set FQDN in /etc/hosts
|
||||
add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail
|
||||
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
|
||||
CONTAINER__fix_hosts() {
|
||||
add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail
|
||||
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
|
||||
}
|
||||
|
||||
. venv/bin/activate
|
||||
python -m tests.MockMailmanServer &
|
||||
python -m tests.MockSMTPServer &
|
||||
python -m tests.MockCloudStackServer &
|
||||
python -m tests.MockHarborServer &
|
||||
IMAGE__setup() {
|
||||
IMAGE__ceod_setup
|
||||
}
|
||||
|
||||
auth_setup mail
|
||||
|
||||
# for the VHostManager
|
||||
mkdir -p /run/ceod/member-vhosts
|
||||
|
||||
# sync with phosphoric-acid
|
||||
nc -l 0.0.0.0 9000 &
|
||||
CONTAINER__setup() {
|
||||
CONTAINER__fix_resolv_conf
|
||||
CONTAINER__fix_hosts
|
||||
CONTAINER__ceod_setup
|
||||
CONTAINER__auth_setup mail
|
||||
# for the VHostManager
|
||||
mkdir -p /run/ceod/member-vhosts
|
||||
# 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 &
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
# 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 ) )
|
|
@ -4,29 +4,44 @@ set -ex
|
|||
|
||||
. .drone/common.sh
|
||||
|
||||
# set FQDN in /etc/hosts
|
||||
add_fqdn_to_hosts "$(get_ip_addr $(hostname))" phosphoric-acid
|
||||
add_fqdn_to_hosts "$(get_ip_addr auth1)" auth1
|
||||
add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee
|
||||
# mail container doesn't run in CI
|
||||
if [ -z "$CI" ]; then
|
||||
add_fqdn_to_hosts $(get_ip_addr mail) mail
|
||||
fi
|
||||
CONTAINER__fix_hosts() {
|
||||
add_fqdn_to_hosts "$(get_ip_addr $(hostname))" phosphoric-acid
|
||||
add_fqdn_to_hosts "$(get_ip_addr auth1)" auth1
|
||||
add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee
|
||||
# mail container doesn't run in CI
|
||||
if [ -z "$CI" ]; then
|
||||
add_fqdn_to_hosts $(get_ip_addr mail) mail
|
||||
fi
|
||||
}
|
||||
|
||||
auth_setup phosphoric-acid
|
||||
CONTAINER__setup_userdirs() {
|
||||
# initialize the skel directory
|
||||
shopt -s dotglob
|
||||
mkdir -p /users/skel
|
||||
cp /etc/skel/* /users/skel/
|
||||
|
||||
# initialize the skel directory
|
||||
shopt -s dotglob
|
||||
mkdir -p /users/skel
|
||||
cp /etc/skel/* /users/skel/
|
||||
# create directories for users
|
||||
for user in ctdalek regular1 exec1; do
|
||||
mkdir -p /users/$user
|
||||
chown $user:$user /users/$user
|
||||
done
|
||||
}
|
||||
|
||||
# create directories for users
|
||||
for user in ctdalek regular1 exec1; do
|
||||
mkdir -p /users/$user
|
||||
chown $user:$user /users/$user
|
||||
done
|
||||
IMAGE__setup() {
|
||||
IMAGE__ceod_setup
|
||||
# git is required by the ClubWebHostingService
|
||||
apt install --no-install-recommends -y git
|
||||
}
|
||||
|
||||
sync_with coffee
|
||||
if [ -z "$CI" ]; then
|
||||
sync_with mail
|
||||
fi
|
||||
CONTAINER__setup() {
|
||||
CONTAINER__fix_resolv_conf
|
||||
CONTAINER__fix_hosts
|
||||
CONTAINER__ceod_setup
|
||||
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
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ include /etc/ldap/schema/rfc2307bis.schema
|
|||
include /etc/ldap/schema/inetorgperson.schema
|
||||
include /etc/ldap/schema/sudo.schema
|
||||
include /etc/ldap/schema/csc.schema
|
||||
include /etc/ldap/schema/mock_ad.schema
|
||||
include /etc/ldap/schema/misc.schema
|
||||
|
||||
pidfile /var/run/slapd/slapd.pid
|
||||
|
@ -40,6 +41,11 @@ access to *
|
|||
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||
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
|
||||
access to *
|
||||
by dn="cn=ceod,dc=csclub,dc=internal" write
|
||||
|
|
|
@ -106,3 +106,18 @@ objectClass: person
|
|||
objectClass: top
|
||||
uid: exec3
|
||||
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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
|
||||
|
||||
exit $?
|
|
@ -1,6 +1,10 @@
|
|||
# If you update this file, please also update the extend-diff-ignore option
|
||||
# in debian/source/options.
|
||||
|
||||
*.key
|
||||
*.gpg
|
||||
*.pgp
|
||||
|
||||
__pycache__/
|
||||
/venv/
|
||||
/dist/
|
||||
|
|
60
PACKAGING.md
60
PACKAGING.md
|
@ -9,11 +9,16 @@ 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)
|
||||
for instructions.
|
||||
|
||||
Make sure you are in the `csc-mirror` group too.
|
||||
|
||||
## Creating the package
|
||||
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):
|
||||
```sh
|
||||
podman run -it --name pyceo-packaging -v "$PWD":"$PWD" -w "$PWD" debian:bullseye bash
|
||||
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm 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.
|
||||
For example, if you're creating a package for bullseye, you should be using the debian:bullseye
|
||||
|
@ -24,22 +29,39 @@ Here are some of the prerequisites you'll need to build the deb files
|
|||
```sh
|
||||
apt update
|
||||
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.
|
||||
|
||||
Update VERSION.txt to the next version, and do a git commit.
|
||||
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`).
|
||||
|
||||
Now run `dch -i` and edit the changelog.
|
||||
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
|
||||
|
||||
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
|
||||
```sh
|
||||
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC
|
||||
# (pre-requisite) if container doesn't have your gpg key
|
||||
## 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.
|
||||
|
||||
Now do another git commit (since you edited the changelog file).
|
||||
|
||||
To clean the packages:
|
||||
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**):
|
||||
```sh
|
||||
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
|
||||
```
|
||||
|
@ -47,28 +69,30 @@ rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
|
|||
## Uploading the package
|
||||
Inside the container, go up one directory, and create a tarball with all the package files:
|
||||
```
|
||||
cd ..
|
||||
cd .. # within the container, generated files are in the parent directory of your git repo
|
||||
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
|
||||
container into your current directory, e.g.
|
||||
```
|
||||
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.)
|
||||
Now upload the tarball to a CSC machine, e.g.
|
||||
```
|
||||
scp pyceo.tar.gz mannitol:~/
|
||||
# on "HOST" machine
|
||||
scp pyceo.tar.gz mannitol:~
|
||||
```
|
||||
SSH into that machine and extract the tarball into a separate directory:
|
||||
```
|
||||
ssh mannitol
|
||||
mkdir pyceo-parent
|
||||
mv pyceo.tar.gz pyceo-parent/
|
||||
cd pyceo-parent
|
||||
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
|
||||
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
|
||||
tar zxvf pyceo.tar.gz
|
||||
```
|
||||
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy.
|
||||
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).
|
||||
|
||||
Now upload the package to potassium-benzoate:
|
||||
```
|
||||
|
@ -78,7 +102,21 @@ dupload *.changes
|
|||
|
||||
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
|
||||
```
|
||||
|
||||
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? :')
|
||||
|
|
39
README.md
39
README.md
|
@ -10,17 +10,29 @@ overview of its architecture.
|
|||
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
|
||||
|
||||
## Development
|
||||
### Docker
|
||||
### Podman
|
||||
If you are not modifying code related to email or Mailman, then you may use
|
||||
Docker containers instead, which are much easier to work with than the VM.
|
||||
Podman containers instead, which are much easier to work with than the VM.
|
||||
|
||||
First, make sure you create the virtualenv:
|
||||
If you are using Podman, make sure to set the `DOCKER_HOST` environment variable
|
||||
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
|
||||
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'
|
||||
scripts/build-all-images.sh
|
||||
```
|
||||
Then bring up the containers:
|
||||
```sh
|
||||
docker-compose up -d # or without -d to run in the foreground
|
||||
docker-compose up -d
|
||||
```
|
||||
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.
|
||||
|
@ -183,12 +195,12 @@ replaced by coffee).
|
|||
To run ceod on a single host (as root, since the app needs to read the keytab):
|
||||
```sh
|
||||
export FLASK_APP=ceod.api
|
||||
export FLASK_ENV=development
|
||||
export FLASK_DEBUG=true
|
||||
flask run -h 0.0.0.0 -p 9987
|
||||
```
|
||||
|
||||
Sometimes changes you make in the source code don't show up while Flask
|
||||
is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
|
||||
is running. Stop the flask app (Ctrl-C), run `scripts/clear_cache.sh`, then
|
||||
restart the app.
|
||||
|
||||
## Interacting with the application
|
||||
|
@ -214,14 +226,23 @@ curl -V
|
|||
```
|
||||
Your should see 'SPNEGO' in the 'Features' section.
|
||||
|
||||
Here's an example of making a request to an endpoint which writes to LDAP:
|
||||
Here's an example of making a request to add a user (in the Docker container):
|
||||
```sh
|
||||
# Get a Kerberos TGT first
|
||||
# If you're root, switch to the ctdalek user first
|
||||
su ctdalek
|
||||
# Get a Kerberos TGT (password is krb5)
|
||||
kinit
|
||||
# Make the request
|
||||
curl --negotiate -u : --service-name ceod --delegation always \
|
||||
-d '{"uid":"test_1","cn":"Test One","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \
|
||||
-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
|
||||
|
|
|
@ -1 +1 @@
|
|||
1.0.21
|
||||
1.0.31
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from zope import component
|
||||
|
@ -9,6 +8,7 @@ from .krb_check import krb_check
|
|||
from .tui.start import main as tui_main
|
||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||
from ceo_common.model import Config, HTTPClient
|
||||
from ceo_common.utils import is_in_development
|
||||
|
||||
|
||||
def register_services():
|
||||
|
@ -19,8 +19,7 @@ def register_services():
|
|||
if 'CEO_CONFIG' in os.environ:
|
||||
config_file = os.environ['CEO_CONFIG']
|
||||
else:
|
||||
# This is a hack to determine if we're in the dev env or not
|
||||
if socket.getfqdn().endswith('.csclub.internal'):
|
||||
if is_in_development():
|
||||
config_file = './tests/ceo_dev.ini'
|
||||
else:
|
||||
config_file = '/etc/csc/ceo.ini'
|
||||
|
|
|
@ -28,6 +28,11 @@ def activate():
|
|||
'Congratulations! Your cloud account has been activated.',
|
||||
f'You may now login into https://cloud.{base_domain} with your CSC credentials.',
|
||||
"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:
|
||||
click.echo(line)
|
||||
|
|
|
@ -149,3 +149,15 @@ def delete(group_name):
|
|||
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
|
||||
resp = http_delete(f'/api/groups/{group_name}')
|
||||
handle_stream_response(resp, DeleteGroupTransaction.operations)
|
||||
|
||||
|
||||
@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)
|
||||
|
|
|
@ -3,13 +3,16 @@ from typing import Dict
|
|||
|
||||
import click
|
||||
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, \
|
||||
get_failed_operations, user_dict_lines, get_adduser_operations
|
||||
from .utils import handle_stream_response, handle_sync_response, print_lines, \
|
||||
check_if_in_development
|
||||
from ceo_common.interfaces import IConfig
|
||||
from ceo_common.model.Term import get_terms_for_new_user
|
||||
from ceod.transactions.members import DeleteMemberTransaction
|
||||
|
||||
|
||||
|
@ -36,6 +39,11 @@ def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_ad
|
|||
cfg = component.getUtility(IConfig)
|
||||
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
|
||||
resp = http_get('/api/uwldap/' + username)
|
||||
if resp.ok:
|
||||
|
@ -48,7 +56,7 @@ def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_ad
|
|||
sn = result['sn']
|
||||
if program is None and result.get('program'):
|
||||
program = result['program']
|
||||
if forwarding_address is None:
|
||||
if forwarding_address is None and result.get('mail_local_addresses'):
|
||||
forwarding_address = result['mail_local_addresses'][0]
|
||||
if cn is None:
|
||||
cn = click.prompt('Full name')
|
||||
|
@ -155,7 +163,7 @@ def modify(username, login_shell, forwarding_addresses):
|
|||
@click.option('--clubrep', is_flag=True, default=False,
|
||||
help='Add non-member terms instead of member terms')
|
||||
def renew(username, num_terms, clubrep):
|
||||
terms = get_terms_for_renewal(username, num_terms, clubrep)
|
||||
terms = get_terms_for_renewal_for_user(username, num_terms, clubrep)
|
||||
|
||||
if clubrep:
|
||||
body = {'non_member_terms': terms}
|
||||
|
|
|
@ -16,13 +16,22 @@ def positions():
|
|||
def get():
|
||||
resp = http_get('/api/positions')
|
||||
result = handle_sync_response(resp)
|
||||
print_colon_kv(result.items())
|
||||
print_colon_kv([
|
||||
(position, ', '.join(usernames))
|
||||
for position, usernames in result.items()
|
||||
])
|
||||
|
||||
|
||||
@positions.command(short_help='Update positions')
|
||||
def set(**kwargs):
|
||||
body = {k.replace('_', '-'): v for k, v in kwargs.items()}
|
||||
print_body = {k: v or '' for k, v in body.items()}
|
||||
body = {
|
||||
k.replace('_', '-'): v.replace(' ', '').split(',') if v else None
|
||||
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:')
|
||||
print_colon_kv(print_body.items())
|
||||
click.confirm('Do you want to continue?', abort=True)
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import socket
|
||||
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from ceo_common.utils import is_in_development
|
||||
from ..utils import space_colon_kv, generic_handle_stream_response
|
||||
from .CLIStreamResponseHandler import CLIStreamResponseHandler
|
||||
|
||||
|
@ -53,6 +52,6 @@ def handle_sync_response(resp: requests.Response):
|
|||
|
||||
def check_if_in_development() -> bool:
|
||||
"""Aborts if we are not currently in the dev environment."""
|
||||
if not socket.getfqdn().endswith('.csclub.internal'):
|
||||
if not is_in_development():
|
||||
click.echo('This command may only be called during development.')
|
||||
raise Abort()
|
||||
|
|
|
@ -1,38 +1,22 @@
|
|||
from typing import List
|
||||
|
||||
from .utils import http_get
|
||||
from ceo_common.model import Term
|
||||
from ceo_common.model.Term import get_terms_for_renewal
|
||||
import ceo.cli.utils as cli_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_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(
|
||||
def get_terms_for_renewal_for_user(
|
||||
username: str, num_terms: int, clubrep: bool, tui_controller=None,
|
||||
) -> List[str]:
|
||||
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:
|
||||
result = cli_utils.handle_sync_response(resp)
|
||||
else:
|
||||
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 max_term is not None and max_term >= current_term:
|
||||
next_term = max_term + 1
|
||||
if clubrep:
|
||||
return get_terms_for_renewal(result.get('non_member_terms'), num_terms)
|
||||
else:
|
||||
next_term = Term.current()
|
||||
|
||||
terms = [next_term + i for i in range(num_terms)]
|
||||
return list(map(str, terms))
|
||||
return get_terms_for_renewal(result.get('terms'), num_terms)
|
||||
|
|
|
@ -3,9 +3,9 @@ from threading import Thread
|
|||
from ...utils import http_get
|
||||
from .Controller import Controller
|
||||
from .AddUserTransactionController import AddUserTransactionController
|
||||
import ceo.term_utils as term_utils
|
||||
from ceo.tui.models import TransactionModel
|
||||
from ceo.tui.views import AddUserConfirmationView, TransactionView
|
||||
from ceo_common.model.Term import get_terms_for_new_user
|
||||
from ceod.transactions.members import AddMemberTransaction
|
||||
|
||||
|
||||
|
@ -26,7 +26,7 @@ class AddUserController(Controller):
|
|||
body['program'] = self.model.program
|
||||
if self.model.forwarding_address:
|
||||
body['forwarding_addresses'] = [self.model.forwarding_address]
|
||||
new_terms = term_utils.get_terms_for_new_user(self.model.num_terms)
|
||||
new_terms = get_terms_for_new_user(self.model.num_terms)
|
||||
if self.model.membership_type == 'club_rep':
|
||||
body['non_member_terms'] = new_terms
|
||||
else:
|
||||
|
@ -106,5 +106,5 @@ class AddUserController(Controller):
|
|||
self.model.first_name = data.get('given_name', '')
|
||||
self.model.last_name = data.get('sn', '')
|
||||
self.model.program = data.get('program', '')
|
||||
self.model.forwarding_address = data.get('mail_local_addresses', [''])[0]
|
||||
self.model.forwarding_address = (data.get('mail_local_addresses') or [''])[0]
|
||||
self.app.run_in_main_loop(self._on_lookup_user_success)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from abc import ABC
|
||||
|
||||
import ceo.tui.utils as utils
|
||||
from ceo_common.utils import validate_username
|
||||
|
||||
|
||||
# NOTE: one controller can control multiple views,
|
||||
|
@ -52,8 +53,9 @@ class Controller(ABC):
|
|||
def get_username_from_view(self):
|
||||
username = self.view.username_edit.edit_text
|
||||
# TODO: share validation logic between CLI and TUI
|
||||
if not username:
|
||||
self.view.popup('Username must not be empty')
|
||||
verification_res = validate_username(username)
|
||||
if not verification_res.is_valid:
|
||||
self.view.popup(verification_res.error_message)
|
||||
raise Controller.InvalidInput()
|
||||
return username
|
||||
|
||||
|
|
|
@ -19,8 +19,8 @@ class GetPositionsController(Controller):
|
|||
positions = tui_utils.handle_sync_response(resp, self)
|
||||
except Controller.RequestFailed:
|
||||
return
|
||||
for pos, username in positions.items():
|
||||
self.model.positions[pos] = username
|
||||
for pos, usernames in positions.items():
|
||||
self.model.positions[pos] = ','.join(usernames)
|
||||
|
||||
def target():
|
||||
self.view.flash_text.set_text('')
|
||||
|
|
|
@ -28,7 +28,7 @@ class RenewUserController(SyncRequestController):
|
|||
|
||||
def _get_next_terms(self):
|
||||
try:
|
||||
self.model.new_terms = term_utils.get_terms_for_renewal(
|
||||
self.model.new_terms = term_utils.get_terms_for_renewal_for_user(
|
||||
self.model.username,
|
||||
self.model.num_terms,
|
||||
self.model.membership_type == 'club_rep',
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
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)
|
|
@ -17,7 +17,7 @@ class SetPositionsController(Controller):
|
|||
body = {}
|
||||
for pos, field in self.view.position_fields.items():
|
||||
if field.edit_text != '':
|
||||
body[pos] = field.edit_text
|
||||
body[pos] = field.edit_text.replace(' ', '').split(',')
|
||||
model = TransactionModel(
|
||||
UpdateMemberPositionsTransaction.operations,
|
||||
'POST', '/api/positions', json=body
|
||||
|
@ -37,8 +37,8 @@ class SetPositionsController(Controller):
|
|||
positions = tui_utils.handle_sync_response(resp, self)
|
||||
except Controller.RequestFailed:
|
||||
return
|
||||
for pos, username in positions.items():
|
||||
self.model.positions[pos] = username
|
||||
for pos, usernames in positions.items():
|
||||
self.model.positions[pos] = ','.join(usernames)
|
||||
|
||||
def target():
|
||||
self.view.flash_text.set_text('')
|
||||
|
|
|
@ -8,6 +8,7 @@ from .ResetPasswordController import ResetPasswordController
|
|||
from .ChangeLoginShellController import ChangeLoginShellController
|
||||
from .AddGroupController import AddGroupController
|
||||
from .GetGroupController import GetGroupController
|
||||
from .SearchGroupController import SearchGroupController
|
||||
from .AddMemberToGroupController import AddMemberToGroupController
|
||||
from .RemoveMemberFromGroupController import RemoveMemberFromGroupController
|
||||
from .CreateDatabaseController import CreateDatabaseController
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
class SearchGroupModel:
|
||||
name = 'SearchGroup'
|
||||
title = 'Search groups'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.resp_json = None
|
||||
self.count = 10
|
||||
self.want_info = False
|
|
@ -5,6 +5,7 @@ from .ResetPasswordModel import ResetPasswordModel
|
|||
from .ChangeLoginShellModel import ChangeLoginShellModel
|
||||
from .AddGroupModel import AddGroupModel
|
||||
from .GetGroupModel import GetGroupModel
|
||||
from .SearchGroupModel import SearchGroupModel
|
||||
from .AddMemberToGroupModel import AddMemberToGroupModel
|
||||
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
|
||||
from .CreateDatabaseModel import CreateDatabaseModel
|
||||
|
@ -29,6 +30,7 @@ class WelcomeModel:
|
|||
'Groups': [
|
||||
AddGroupModel,
|
||||
GetGroupModel,
|
||||
SearchGroupModel,
|
||||
AddMemberToGroupModel,
|
||||
RemoveMemberFromGroupModel,
|
||||
],
|
||||
|
|
|
@ -6,6 +6,7 @@ from .ResetPasswordModel import ResetPasswordModel
|
|||
from .ChangeLoginShellModel import ChangeLoginShellModel
|
||||
from .AddGroupModel import AddGroupModel
|
||||
from .GetGroupModel import GetGroupModel
|
||||
from .SearchGroupModel import SearchGroupModel
|
||||
from .AddMemberToGroupModel import AddMemberToGroupModel
|
||||
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
|
||||
from .CreateDatabaseModel import CreateDatabaseModel
|
||||
|
|
|
@ -25,6 +25,7 @@ def handle_sync_response(resp, controller):
|
|||
raise Controller.RequestFailed()
|
||||
|
||||
|
||||
# this can probably be simplified with getattr or something
|
||||
def get_mvc(app, name):
|
||||
if name == WelcomeModel.name:
|
||||
model = WelcomeModel()
|
||||
|
@ -58,6 +59,10 @@ def get_mvc(app, name):
|
|||
model = GetGroupModel()
|
||||
controller = GetGroupController(model, 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:
|
||||
model = AddMemberToGroupModel()
|
||||
controller = AddMemberToGroupController(model, app)
|
||||
|
|
|
@ -53,6 +53,10 @@ class AddUserView(ColumnView):
|
|||
urwid.Text('Program:', align='right'),
|
||||
self.program_edit
|
||||
),
|
||||
(
|
||||
urwid.Text('Forwarding address:', align='right'),
|
||||
self.forwarding_address_edit
|
||||
),
|
||||
(
|
||||
urwid.Text('Number of terms:', align='right'),
|
||||
self.num_terms_edit
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
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
|
|
@ -0,0 +1,17 @@
|
|||
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)
|
|
@ -16,6 +16,8 @@ from .AddGroupView import AddGroupView
|
|||
from .AddGroupConfirmationView import AddGroupConfirmationView
|
||||
from .GetGroupView import GetGroupView
|
||||
from .GetGroupResponseView import GetGroupResponseView
|
||||
from .SearchGroupView import SearchGroupView
|
||||
from .SearchGroupResponseView import SearchGroupResponseView
|
||||
from .AddMemberToGroupView import AddMemberToGroupView
|
||||
from .AddMemberToGroupConfirmationView import AddMemberToGroupConfirmationView
|
||||
from .RemoveMemberFromGroupView import RemoveMemberFromGroupView
|
||||
|
|
|
@ -5,8 +5,15 @@ position_names = {
|
|||
'secretary': "Secretary",
|
||||
'sysadmin': "Sysadmin",
|
||||
'cro': "Chief Returning Officer",
|
||||
'librarian': "Librarian",
|
||||
'imapd': "IMAPD",
|
||||
'webmaster': "Web Master",
|
||||
'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',
|
||||
}
|
||||
|
|
|
@ -87,6 +87,9 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
|
|||
maxlen = max(len(key) for key, val in pairs)
|
||||
for key, val in pairs:
|
||||
if key != '':
|
||||
if not val:
|
||||
lines.append(key + ':')
|
||||
continue
|
||||
prefix = key + ': '
|
||||
else:
|
||||
# assume this is a continuation from the previous line
|
||||
|
@ -136,6 +139,8 @@ def user_dict_kv(d: Dict) -> List[Tuple[str]]:
|
|||
pairs.append(('non-member terms', ','.join(_terms)))
|
||||
if 'password' in d:
|
||||
pairs.append(('password', d['password']))
|
||||
if 'groups' in d:
|
||||
pairs.append(('groups', ','.join(d['groups'])))
|
||||
return pairs
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
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.
|
||||
"""
|
|
@ -19,8 +19,7 @@ class ICloudStackService(Interface):
|
|||
The dict is mapping of usernames to account IDs.
|
||||
"""
|
||||
|
||||
def delete_account(account_id: str):
|
||||
def delete_account(username: str, account_id: str):
|
||||
"""
|
||||
Delete the given CloudStack account.
|
||||
Note that a CloudStack account ID must be given, not a username.
|
||||
"""
|
||||
|
|
|
@ -33,6 +33,11 @@ class ILDAPService(Interface):
|
|||
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):
|
||||
"""Remove this user from the database."""
|
||||
|
||||
|
|
|
@ -1,20 +1,23 @@
|
|||
from typing import List, Union
|
||||
import typing
|
||||
from typing import List, Optional
|
||||
|
||||
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):
|
||||
"""Represents the UW LDAP database."""
|
||||
|
||||
def get_user(username: str):
|
||||
def get_user(username: str) -> Optional['UWLDAPRecord']:
|
||||
"""
|
||||
Return the LDAP record for the given user, or
|
||||
None if no such record exists.
|
||||
|
||||
:rtype: Union[UWLDAPRecord, None]
|
||||
"""
|
||||
|
||||
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
|
||||
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
|
||||
"""
|
||||
Return the programs for the given users from UWLDAP.
|
||||
If no record or program is found for a user, their entry in
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from .IADLDAPService import IADLDAPService
|
||||
from .ICloudResourceManager import ICloudResourceManager
|
||||
from .ICloudStackService import ICloudStackService
|
||||
from .IKerberosService import IKerberosService
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
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
|
||||
)
|
|
@ -6,7 +6,7 @@ from requests_gssapi import HTTPSPNEGOAuth
|
|||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||
from ceo_common.interfaces import IConfig, IHTTPClient, IKerberosService
|
||||
|
||||
|
||||
@implementer(IHTTPClient)
|
||||
|
@ -40,10 +40,18 @@ class HTTPClient:
|
|||
'opportunistic_auth': True,
|
||||
'target_name': gssapi.Name('ceod/' + host),
|
||||
}
|
||||
if flask.has_request_context() and 'client_token' in g:
|
||||
if flask.has_request_context():
|
||||
# This is reached when we are the server and the client has
|
||||
# forwarded their credentials to us.
|
||||
spnego_kwargs['creds'] = gssapi.Credentials(token=g.client_token)
|
||||
token = None
|
||||
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:
|
||||
# This is reached when we are the client and we want to
|
||||
# forward our credentials to the server.
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import datetime
|
||||
from typing import List, Union
|
||||
|
||||
import ceo_common.utils as utils
|
||||
|
||||
|
@ -81,3 +82,39 @@ class Term:
|
|||
month = self.seasons.index(c) * 4 + 1
|
||||
day = 1
|
||||
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))
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
from .ADLDAPRecord import ADLDAPRecord
|
||||
from .Config import Config
|
||||
from .HTTPClient import HTTPClient
|
||||
from .RemoteMailmanService import RemoteMailmanService
|
||||
from .Term import Term
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
|
|
|
@ -1,7 +1,78 @@
|
|||
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:
|
||||
# We place this in a separate function so that we can mock it out
|
||||
# in our unit tests.
|
||||
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')
|
||||
|
|
|
@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
|
|||
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
|
||||
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
|
||||
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
|
||||
IContainerRegistryService, IClubWebHostingService
|
||||
IContainerRegistryService, IClubWebHostingService, IADLDAPService
|
||||
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
|
||||
from ceod.api.spnego import init_spnego
|
||||
from ceod.model import KerberosService, LDAPService, FileService, \
|
||||
MailmanService, MailService, UWLDAPService, CloudStackService, \
|
||||
CloudResourceManager, KubernetesService, VHostManager, \
|
||||
ContainerRegistryService, ClubWebHostingService
|
||||
ContainerRegistryService, ClubWebHostingService, ADLDAPService
|
||||
from ceod.db import MySQLService, PostgreSQLService
|
||||
|
||||
|
||||
|
@ -36,6 +36,15 @@ def create_app(flask_config={}):
|
|||
from ceod.api import 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
|
||||
if hostname == cfg.get('ceod_mailman_host'):
|
||||
from ceod.api import mailman
|
||||
|
@ -53,15 +62,6 @@ def create_app(flask_config={}):
|
|||
from ceod.api import 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)
|
||||
|
||||
@app.route('/ping')
|
||||
|
@ -74,7 +74,7 @@ def create_app(flask_config={}):
|
|||
|
||||
def register_services(app):
|
||||
# Config
|
||||
if app.config.get('ENV') == 'development' and 'CEOD_CONFIG' not in os.environ:
|
||||
if app.config.get('DEBUG') and 'CEOD_CONFIG' not in os.environ:
|
||||
with importlib.resources.path('tests', 'ceod_dev.ini') as p:
|
||||
config_file = p.__fspath__()
|
||||
else:
|
||||
|
@ -112,9 +112,13 @@ def register_services(app):
|
|||
mail_srv = MailService()
|
||||
component.provideUtility(mail_srv, IMailService)
|
||||
|
||||
# UWLDAPService
|
||||
uwldap_srv = UWLDAPService()
|
||||
component.provideUtility(uwldap_srv, IUWLDAPService)
|
||||
# UWLDAPService, ADLDAPService
|
||||
if hostname == cfg.get('ceod_admin_host'):
|
||||
uwldap_srv = UWLDAPService()
|
||||
component.provideUtility(uwldap_srv, IUWLDAPService)
|
||||
|
||||
adldap_srv = ADLDAPService()
|
||||
component.provideUtility(adldap_srv, IADLDAPService)
|
||||
|
||||
# ClubWebHostingService
|
||||
if hostname == cfg.get('ceod_webhosting_host'):
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
from flask import Blueprint, request
|
||||
from flask.json import jsonify
|
||||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy, \
|
||||
create_streaming_response, development_only
|
||||
create_streaming_response, development_only, requires_admin_creds, \
|
||||
requires_authentication_no_realm, user_is_in_group
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
from ceo_common.utils import fuzzy_result, fuzzy_match
|
||||
from ceod.transactions.groups import (
|
||||
AddGroupTransaction,
|
||||
AddMemberToGroupTransaction,
|
||||
|
@ -11,6 +14,8 @@ from ceod.transactions.groups import (
|
|||
DeleteGroupTransaction,
|
||||
)
|
||||
|
||||
from heapq import heappushpop, nlargest
|
||||
|
||||
bp = Blueprint('groups', __name__)
|
||||
|
||||
|
||||
|
@ -32,9 +37,42 @@ def get_group(group_name):
|
|||
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'])
|
||||
@authz_restrict_to_syscom
|
||||
def add_member_to_group(group_name, username):
|
||||
@requires_admin_creds
|
||||
@requires_authentication_no_realm
|
||||
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(
|
||||
request.args.get('subscribe_to_lists', 'true')
|
||||
)
|
||||
|
@ -47,8 +85,13 @@ def add_member_to_group(group_name, username):
|
|||
|
||||
|
||||
@bp.route('/<group_name>/members/<username>', methods=['DELETE'])
|
||||
@authz_restrict_to_syscom
|
||||
def remove_member_from_group(group_name, username):
|
||||
@requires_admin_creds
|
||||
@requires_authentication_no_realm
|
||||
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(
|
||||
request.args.get('unsubscribe_from_lists', 'true')
|
||||
)
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
from flask import Blueprint, g, request
|
||||
from flask import Blueprint, request
|
||||
from flask.json import jsonify
|
||||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
|
||||
user_is_in_group, requires_authentication_no_realm, \
|
||||
user_is_in_group, requires_authentication_no_realm, requires_admin_creds, \
|
||||
create_streaming_response, development_only, is_truthy
|
||||
from ceo_common.errors import BadRequest, UserAlreadySubscribedError, UserNotSubscribedError
|
||||
from ceo_common.interfaces import ILDAPService, IConfig, IMailService
|
||||
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 (
|
||||
AddMemberTransaction,
|
||||
ModifyMemberTransaction,
|
||||
|
@ -20,20 +22,36 @@ logger = logger_factory(__name__)
|
|||
|
||||
|
||||
@bp.route('/', methods=['POST'], strict_slashes=False)
|
||||
@requires_admin_creds
|
||||
@authz_restrict_to_staff
|
||||
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)
|
||||
terms = body.get('terms')
|
||||
non_member_terms = body.get('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')
|
||||
for attr in ['uid', 'cn', 'given_name', 'sn']:
|
||||
if type(terms) is int:
|
||||
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):
|
||||
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')
|
||||
|
||||
# We need to use the admin creds here because office members may not
|
||||
# directly create new LDAP records.
|
||||
g.need_admin_creds = True
|
||||
uid_validator = validate_username(body['uid'])
|
||||
if not uid_validator.is_valid:
|
||||
raise BadRequest("Attribute 'uid' is missing or invalid")
|
||||
|
||||
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(
|
||||
uid=body['uid'],
|
||||
|
@ -43,7 +61,7 @@ def create_user():
|
|||
program=body.get('program'),
|
||||
terms=terms,
|
||||
non_member_terms=non_member_terms,
|
||||
forwarding_addresses=body.get('forwarding_addresses'),
|
||||
forwarding_addresses=body['forwarding_addresses'],
|
||||
)
|
||||
return create_streaming_response(txn)
|
||||
|
||||
|
@ -61,7 +79,9 @@ def get_user(auth_user: str, username: str):
|
|||
get_forwarding_addresses = True
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
user = ldap_srv.get_user(username)
|
||||
return user.to_dict(get_forwarding_addresses)
|
||||
user_dict = user.to_dict(get_forwarding_addresses)
|
||||
user_dict['groups'] = ldap_srv.get_groups_for_user(username)
|
||||
return user_dict
|
||||
|
||||
|
||||
@bp.route('/<username>', methods=['PATCH'])
|
||||
|
@ -81,40 +101,48 @@ def patch_user(auth_user: str, username: str):
|
|||
|
||||
|
||||
@bp.route('/<username>/renew', methods=['POST'])
|
||||
@requires_admin_creds
|
||||
@authz_restrict_to_staff
|
||||
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)
|
||||
terms = body.get('terms')
|
||||
non_member_terms = body.get('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')
|
||||
|
||||
# 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)
|
||||
cfg = component.getUtility(IConfig)
|
||||
user = ldap_srv.get_user(username)
|
||||
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):
|
||||
if user.shadowExpire:
|
||||
user.set_expired(False)
|
||||
try:
|
||||
user.subscribe_to_mailing_list(member_list)
|
||||
logger.debug(f'Subscribed {user.uid} to {member_list}')
|
||||
except UserAlreadySubscribedError:
|
||||
pass
|
||||
logger.debug(f'{user.uid} is already subscribed to {member_list}')
|
||||
|
||||
if body.get('terms'):
|
||||
user.add_terms(body['terms'])
|
||||
if terms:
|
||||
logger.info(f"Renewing member {username} for terms {terms}")
|
||||
user.add_terms(terms)
|
||||
unexpire(user)
|
||||
return {'terms_added': body['terms']}
|
||||
elif body.get('non_member_terms'):
|
||||
user.add_non_member_terms(body['non_member_terms'])
|
||||
return {'terms_added': terms}
|
||||
elif non_member_terms:
|
||||
logger.info(f"Renewing club rep {username} for non-member terms {non_member_terms}")
|
||||
user.add_non_member_terms(non_member_terms)
|
||||
unexpire(user)
|
||||
return {'non_member_terms_added': body['non_member_terms']}
|
||||
return {'non_member_terms_added': non_member_terms}
|
||||
else:
|
||||
raise BadRequest('Must specify either terms or non-member terms')
|
||||
|
||||
|
@ -130,9 +158,12 @@ def reset_user_password(username: str):
|
|||
|
||||
|
||||
@bp.route('/<username>', methods=['DELETE'])
|
||||
@requires_admin_creds
|
||||
@authz_restrict_to_syscom
|
||||
@development_only
|
||||
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)
|
||||
return create_streaming_response(txn)
|
||||
|
||||
|
@ -149,11 +180,13 @@ def expire_users():
|
|||
|
||||
if not dry_run:
|
||||
for member in members:
|
||||
logger.info(f'Expiring {member.uid}')
|
||||
member.set_expired(True)
|
||||
try:
|
||||
member.unsubscribe_from_mailing_list(member_list)
|
||||
logger.debug(f'Unsubscribed {member.uid} from {member_list}')
|
||||
except UserNotSubscribedError:
|
||||
pass
|
||||
logger.debug(f'{member.uid} is already unsubscribed from {member_list}')
|
||||
|
||||
return jsonify([member.uid for member in members])
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ from flask import Blueprint, request
|
|||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, create_streaming_response
|
||||
from ceo_common.errors import BadRequest
|
||||
from ceo_common.interfaces import ILDAPService, IConfig
|
||||
from ceod.transactions.members import UpdateMemberPositionsTransaction
|
||||
|
||||
|
@ -15,7 +16,9 @@ def get_positions():
|
|||
positions = {}
|
||||
for user in ldap_srv.get_users_with_positions():
|
||||
for position in user.positions:
|
||||
positions[position] = user.uid
|
||||
if position not in positions:
|
||||
positions[position] = []
|
||||
positions[position].append(user.uid)
|
||||
|
||||
return positions
|
||||
|
||||
|
@ -29,23 +32,31 @@ def update_positions():
|
|||
required = cfg.get('positions_required')
|
||||
available = cfg.get('positions_available')
|
||||
|
||||
# remove falsy values
|
||||
body = {
|
||||
positions: username for positions, username in body.items()
|
||||
if username
|
||||
}
|
||||
# remove falsy values and parse multiple users in each position
|
||||
# Example: "user1,user2, user3" -> ["user1","user2","user3"]
|
||||
position_to_usernames = {}
|
||||
for position, usernames in body.items():
|
||||
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')
|
||||
|
||||
for position in body.keys():
|
||||
# check for duplicates (i.e. one username specified twice in the same list)
|
||||
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:
|
||||
return {
|
||||
'error': f'unknown position: {position}'
|
||||
}, 400
|
||||
raise BadRequest(f'unknown position: {position}')
|
||||
|
||||
for position in required:
|
||||
if position not in body:
|
||||
return {
|
||||
'error': f'missing required position: {position}'
|
||||
}, 400
|
||||
if position not in position_to_usernames:
|
||||
raise BadRequest(f'missing required position: {position}')
|
||||
|
||||
txn = UpdateMemberPositionsTransaction(body)
|
||||
txn = UpdateMemberPositionsTransaction(position_to_usernames)
|
||||
return create_streaming_response(txn)
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import functools
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import traceback
|
||||
from typing import Callable, List
|
||||
|
||||
from flask import current_app, stream_with_context
|
||||
from flask import current_app, g, stream_with_context
|
||||
from zope import component
|
||||
|
||||
from .spnego import requires_authentication
|
||||
|
@ -40,9 +37,29 @@ def requires_authentication_no_realm(f: Callable) -> Callable:
|
|||
return wrapper
|
||||
|
||||
|
||||
def user_is_in_group(user: str, group: str) -> bool:
|
||||
"""Returns True if `user` is in `group`, False otherwise."""
|
||||
return user in grp.getgrnam(group).gr_mem
|
||||
def requires_admin_creds(f: Callable) -> Callable:
|
||||
"""
|
||||
Forces the next LDAP connection to use the admin Kerberos credentials.
|
||||
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:
|
||||
|
@ -51,8 +68,6 @@ def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable
|
|||
specified groups.
|
||||
"""
|
||||
|
||||
allowed_group_ids = [grp.getgrnam(g).gr_gid for g in allowed_groups]
|
||||
|
||||
@requires_authentication_no_realm
|
||||
@functools.wraps(f)
|
||||
def wrapper(_username: str, *args, **kwargs):
|
||||
|
@ -62,15 +77,14 @@ def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable
|
|||
if username.startswith('ceod/'):
|
||||
# ceod services are always allowed to make internal calls
|
||||
return f(*args, **kwargs)
|
||||
for gid in os.getgrouplist(username, pwd.getpwnam(username).pw_gid):
|
||||
if gid in allowed_group_ids:
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
for group_name in ldap_srv.get_groups_for_user(username):
|
||||
if group_name in allowed_groups:
|
||||
return f(*args, **kwargs)
|
||||
logger.debug(
|
||||
f"User '{username}' denied since they are not in one of {allowed_groups}"
|
||||
)
|
||||
return {
|
||||
'error': f'You must be in one of {allowed_groups}'
|
||||
}, 403
|
||||
return {'error': f'You must be in one of {allowed_groups}'}, 403
|
||||
|
||||
return wrapper
|
||||
|
||||
|
@ -130,7 +144,7 @@ def create_streaming_response(txn: AbstractTransaction):
|
|||
def development_only(f: Callable) -> Callable:
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if current_app.config.get('ENV') == 'development' or \
|
||||
if current_app.config.get('DEBUG') or \
|
||||
current_app.config.get('TESTING'):
|
||||
return f(*args, **kwargs)
|
||||
return {
|
||||
|
|
|
@ -3,15 +3,25 @@ from flask.json import jsonify
|
|||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy
|
||||
from ceo_common.interfaces import IUWLDAPService, ILDAPService
|
||||
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
|
||||
bp = Blueprint('uwldap', __name__)
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
@bp.route('/<username>')
|
||||
def get_user(username: str):
|
||||
uwldap_srv = component.getUtility(IUWLDAPService)
|
||||
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:
|
||||
return {
|
||||
'error': 'user not found',
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
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])
|
|
@ -97,7 +97,7 @@ class CloudResourceManager:
|
|||
resources = accounts[username]['resources']
|
||||
if 'cloudstack' in resources:
|
||||
account_id = accounts[username]['cloudstack_account_id']
|
||||
cloudstack_srv.delete_account(account_id)
|
||||
cloudstack_srv.delete_account(username, account_id)
|
||||
if 'vhost' in resources:
|
||||
vhost_mgr.delete_all_vhosts_for_user(username)
|
||||
if 'k8s' in resources:
|
||||
|
|
|
@ -62,6 +62,7 @@ class CloudStackService:
|
|||
domain_id = self._get_domain_id()
|
||||
url = self._create_url({
|
||||
'command': 'listAccounts',
|
||||
'accounttype': '0', # regular user (exclude domain admin)
|
||||
'domainid': domain_id,
|
||||
'details': 'min',
|
||||
})
|
||||
|
@ -76,7 +77,8 @@ class CloudStackService:
|
|||
for account in d['account']
|
||||
}
|
||||
|
||||
def delete_account(self, account_id: str):
|
||||
def delete_account(self, username: str, account_id: str):
|
||||
logger.info(f'Deleting CloudStack account for {username}')
|
||||
url = self._create_url({
|
||||
'command': 'deleteAccount',
|
||||
'id': account_id,
|
||||
|
|
|
@ -84,16 +84,19 @@ class ClubWebHostingService:
|
|||
logger.debug('Reloading Apache')
|
||||
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):
|
||||
if not os.path.isdir(os.path.join(self.apache_dir, '.git')):
|
||||
logger.debug('No git folder found in Apache directory')
|
||||
return
|
||||
logger.debug('Committing changes to git repository')
|
||||
self._run(['git', 'add', APACHE_DISABLED_CLUBS_FILE], cwd=self.apache_dir)
|
||||
self._run(['git', 'commit', '-m', '[ceo] disable club websites'], cwd=self.apache_dir)
|
||||
self._run(
|
||||
['git', 'add', APACHE_DISABLED_CLUBS_FILE],
|
||||
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):
|
||||
if not self.made_at_least_one_change:
|
||||
|
@ -112,12 +115,13 @@ class ClubWebHostingService:
|
|||
directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive')
|
||||
for directive_path in directive_paths:
|
||||
directive = self.aug.get(directive_path)
|
||||
directive_value = self.aug.get(directive_path + '/arg')
|
||||
if directive == 'DocumentRoot':
|
||||
directive_value = self.aug.get(directive_path + '/arg')
|
||||
match = APACHE_USERDIR_RE.match(directive_value)
|
||||
if match is not None:
|
||||
club_name = match.group('club_name')
|
||||
elif directive == 'ServerAdmin':
|
||||
directive_value = self.aug.get(directive_path + '/arg')
|
||||
club_email = directive_value
|
||||
if club_name is not None:
|
||||
self.clubs[club_name]['email'] = club_email
|
||||
|
@ -157,12 +161,20 @@ class ClubWebHostingService:
|
|||
|
||||
def _site_uses_php(self, club_name: str) -> bool:
|
||||
www = f'{self.clubs_home}/{club_name}/www'
|
||||
if os.path.isdir(www):
|
||||
if not os.path.isdir(www):
|
||||
return False
|
||||
try:
|
||||
# We're just going to look one level deep; that should be good enough.
|
||||
for filename in os.listdir(www):
|
||||
filepath = os.path.join(www, filename)
|
||||
if os.path.isfile(filepath) and filename.endswith('.php'):
|
||||
return True
|
||||
filenames = os.listdir(www)
|
||||
except os.error:
|
||||
# If we're unable to read the directory (e.g. permissions error),
|
||||
# then this means that the Apache user (www-data) can't read it either.
|
||||
# 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
|
||||
|
||||
# This method needs to be called from within a transaction (uses self.clubs)
|
||||
|
@ -224,7 +236,7 @@ class ClubWebHostingService:
|
|||
# STEP 2: send emails to clubs whose websites were disabled
|
||||
clubs_who_were_not_notified = set()
|
||||
for club_name in clubs_to_disable:
|
||||
address = clubs_info['email']
|
||||
address = clubs_info[club_name]['email']
|
||||
if address is None:
|
||||
clubs_who_were_not_notified.add(club_name)
|
||||
continue
|
||||
|
|
|
@ -7,6 +7,9 @@ from zope.interface import implementer
|
|||
|
||||
from ceo_common.errors import UserNotFoundError
|
||||
from ceo_common.interfaces import IContainerRegistryService, IConfig
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
@implementer(IContainerRegistryService)
|
||||
|
@ -70,6 +73,7 @@ class ContainerRegistryService:
|
|||
resp.raise_for_status()
|
||||
|
||||
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
|
||||
resp = self._http_get(f'/projects/{username}/repositories')
|
||||
if resp.status_code == 403:
|
||||
|
|
|
@ -11,6 +11,9 @@ from zope import component
|
|||
from zope.interface import implementer
|
||||
|
||||
from ceo_common.interfaces import IConfig, IKubernetesService
|
||||
from ceo_common.logger_factory import logger_factory
|
||||
|
||||
logger = logger_factory(__name__)
|
||||
|
||||
|
||||
@implementer(IKubernetesService)
|
||||
|
@ -100,6 +103,7 @@ class KubernetesService:
|
|||
return body
|
||||
|
||||
def delete_account(self, username: str):
|
||||
logger.info(f'Deleting Kubernetes namespace for {username}')
|
||||
namespace = self._get_namespace(username)
|
||||
# don't check exit code because namespace might not exist
|
||||
self._run(['kubectl', 'delete', 'namespace', namespace], check=False)
|
||||
|
|
|
@ -98,6 +98,13 @@ class LDAPService:
|
|||
entry = self._get_readable_entry_for_group(conn, cn)
|
||||
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]]:
|
||||
if not usernames:
|
||||
return []
|
||||
|
@ -105,14 +112,14 @@ class LDAPService:
|
|||
filter = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
|
||||
attributes = ['uid', 'cn', 'program']
|
||||
conn.search(self.ldap_users_base, filter, attributes=attributes)
|
||||
return [
|
||||
return sorted([
|
||||
{
|
||||
'uid': entry.uid.value,
|
||||
'cn': entry.cn.value,
|
||||
'program': entry.program.value or 'Unknown',
|
||||
}
|
||||
for entry in conn.entries
|
||||
]
|
||||
], key=lambda member: member['uid'])
|
||||
|
||||
def get_users_with_positions(self) -> List[IUser]:
|
||||
conn = self._get_ldap_conn()
|
||||
|
@ -309,12 +316,14 @@ class LDAPService:
|
|||
self,
|
||||
dry_run: bool = False,
|
||||
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,
|
||||
):
|
||||
if members:
|
||||
filter = '(|' + ''.join([f'(uid={uid})' for uid in members]) + ')'
|
||||
else:
|
||||
filter = '(objectClass=*)'
|
||||
filter = '(objectClass=member)'
|
||||
conn = self._get_ldap_conn()
|
||||
conn.search(
|
||||
self.ldap_users_base, filter, attributes=['uid', 'program'])
|
||||
|
@ -329,12 +338,17 @@ class LDAPService:
|
|||
batch_uids = uids[i:i + uwldap_batch_size]
|
||||
batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids)
|
||||
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 = [
|
||||
(uids[i], csc_programs[i], uw_programs[i])
|
||||
for i in range(len(uids))
|
||||
if csc_programs[i] != uw_programs[i] and (
|
||||
uw_programs[i] not in (None, 'expired', 'orphaned')
|
||||
)
|
||||
if csc_programs[i] != uw_programs[i]
|
||||
]
|
||||
if dry_run:
|
||||
return users_to_change
|
||||
|
@ -346,7 +360,11 @@ class LDAPService:
|
|||
return users_to_change
|
||||
|
||||
def _get_club_uids(self, conn: ldap3.Connection) -> List[str]:
|
||||
conn.search(self.ldap_users_base, '(objectClass=club)', attributes=['uid'])
|
||||
conn.extend.standard.paged_search(self.ldap_users_base,
|
||||
'(objectClass=club)',
|
||||
attributes=['uid'],
|
||||
paged_size=50,
|
||||
generator=False)
|
||||
return [entry.uid.value for entry in conn.entries]
|
||||
|
||||
def get_clubs(self) -> List[IGroup]:
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
from typing import Union, List
|
||||
from typing import List, Optional
|
||||
|
||||
import ldap3
|
||||
from zope import component
|
||||
from zope.interface import implementer
|
||||
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
from ceo_common.interfaces import IUWLDAPService, IConfig
|
||||
from ceo_common.model import UWLDAPRecord
|
||||
|
||||
|
||||
@implementer(IUWLDAPService)
|
||||
|
@ -20,7 +20,7 @@ class UWLDAPService:
|
|||
self.uwldap_server_url, auto_bind=True, read_only=True,
|
||||
raise_exceptions=True)
|
||||
|
||||
def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
|
||||
def get_user(self, username: str) -> Optional[UWLDAPRecord]:
|
||||
conn = self._get_conn()
|
||||
conn.search(
|
||||
self.uwldap_base, f'(uid={username})',
|
||||
|
@ -29,7 +29,7 @@ class UWLDAPService:
|
|||
return None
|
||||
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
|
||||
|
||||
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
|
||||
def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]:
|
||||
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
|
||||
programs = [None] * len(usernames)
|
||||
user_indices = {uid: i for i, uid in enumerate(usernames)}
|
||||
|
|
|
@ -86,7 +86,6 @@ class User:
|
|||
'is_club': self.is_club(),
|
||||
'is_club_rep': self.is_club_rep,
|
||||
'program': self.program or 'Unknown',
|
||||
'shadowExpire': self.shadowExpire,
|
||||
}
|
||||
if self.sn and self.given_name:
|
||||
data['sn'] = self.sn
|
||||
|
@ -103,6 +102,8 @@ class User:
|
|||
data['mail_local_addresses'] = self.mail_local_addresses
|
||||
if get_forwarding_addresses:
|
||||
data['forwarding_addresses'] = self.get_forwarding_addresses()
|
||||
if self.shadowExpire:
|
||||
data['shadow_expire'] = self.shadowExpire
|
||||
return data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
@ -169,7 +170,7 @@ class User:
|
|||
is_club_rep=attrs.get('isClubRep', [False])[0],
|
||||
is_club=('club' in attrs['objectClass']),
|
||||
is_member_or_club_rep=('member' in attrs['objectClass']),
|
||||
shadowExpire=attrs.get('shadowExpire'),
|
||||
shadowExpire=attrs.get('shadowExpire', [None])[0],
|
||||
ldap3_entry=entry,
|
||||
)
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import os
|
|||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import List, Dict, Tuple
|
||||
from typing import List, Dict, Tuple, Union
|
||||
|
||||
import jinja2
|
||||
from zope import component
|
||||
|
@ -53,6 +53,7 @@ class VHostManager:
|
|||
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_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_dir = '/root/.acme.sh'
|
||||
|
@ -82,12 +83,12 @@ class VHostManager:
|
|||
"""Return a list of all vhost files for this user."""
|
||||
return glob.glob(os.path.join(self.vhost_dir, username + '_*'))
|
||||
|
||||
def _run(self, args: List[str]):
|
||||
subprocess.run(args, check=True)
|
||||
def _run(self, args: Union[List[str], str], **kwargs):
|
||||
subprocess.run(args, check=True, **kwargs)
|
||||
|
||||
def _reload_web_server(self):
|
||||
logger.debug('Reloading NGINX')
|
||||
self._run(['systemctl', 'reload', 'nginx'])
|
||||
self._run(self.reload_web_server_cmd, shell=True)
|
||||
|
||||
def is_valid_domain(self, username: str, domain: str) -> bool:
|
||||
if VALID_DOMAIN_RE.match(domain) is None:
|
||||
|
@ -150,7 +151,7 @@ class VHostManager:
|
|||
self.acme_sh, '--install-cert', '-d', domain,
|
||||
'--key-file', key_path,
|
||||
'--fullchain-file', cert_path,
|
||||
'--reloadcmd', 'systemctl reload nginx',
|
||||
'--reloadcmd', self.reload_web_server_cmd,
|
||||
])
|
||||
|
||||
def _delete_cert(self, domain: str, cert_path: str, key_path: str):
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from .ADLDAPService import ADLDAPService
|
||||
from .CloudResourceManager import CloudResourceManager
|
||||
from .CloudStackService import CloudStackService
|
||||
from .KerberosService import KerberosService
|
||||
|
@ -5,7 +6,6 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
|
|||
from .User import User
|
||||
from .Group import Group
|
||||
from .UWLDAPService import UWLDAPService
|
||||
from .UWLDAPRecord import UWLDAPRecord
|
||||
from .FileService import FileService
|
||||
from .SudoRole import SudoRole
|
||||
from .MailService import MailService
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
from typing import Dict, List
|
||||
|
||||
from zope import component
|
||||
|
||||
|
@ -20,15 +20,19 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
|
|||
'subscribe_to_mailing_lists',
|
||||
]
|
||||
|
||||
def __init__(self, positions_reversed: Dict[str, str]):
|
||||
def __init__(self, position_to_usernames: Dict[str, List[str]]):
|
||||
# positions_reversed is position -> username
|
||||
super().__init__()
|
||||
self.ldap_srv = component.getUtility(ILDAPService)
|
||||
|
||||
# Reverse the dict so it's easier to use (username -> positions)
|
||||
self.positions = defaultdict(list)
|
||||
for position, username in positions_reversed.items():
|
||||
self.positions[username].append(position)
|
||||
for position, usernames in position_to_usernames.items():
|
||||
if isinstance(usernames, list):
|
||||
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)
|
||||
self.users: Dict[str, IUser] = {}
|
||||
|
@ -42,7 +46,7 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
|
|||
mailing_lists = cfg.get('auxiliary mailing lists_exec')
|
||||
|
||||
# position -> username
|
||||
new_positions_reversed = {} # For returning result
|
||||
new_position_to_usernames = {} # For returning result
|
||||
|
||||
# retrieve User objects and cache them
|
||||
for username in self.positions:
|
||||
|
@ -64,7 +68,9 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
|
|||
|
||||
self.old_positions[username] = old_positions
|
||||
for position in new_positions:
|
||||
new_positions_reversed[position] = username
|
||||
if position not in new_position_to_usernames:
|
||||
new_position_to_usernames[position] = []
|
||||
new_position_to_usernames[position].append(username)
|
||||
yield 'update_positions_ldap'
|
||||
|
||||
# update exec group in LDAP
|
||||
|
@ -97,7 +103,7 @@ class UpdateMemberPositionsTransaction(AbstractTransaction):
|
|||
else:
|
||||
yield 'subscribe_to_mailing_lists'
|
||||
|
||||
self.finish(new_positions_reversed)
|
||||
self.finish(new_position_to_usernames)
|
||||
|
||||
def rollback(self):
|
||||
if 'update_exec_group_ldap' in self.finished_operations:
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
chmod 600 /etc/csc/ceod.ini
|
|
@ -1,3 +1,79 @@
|
|||
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
|
||||
|
||||
* Fix bug in ContainerRegistryService.
|
||||
|
|
|
@ -1 +1 @@
|
|||
10
|
||||
13
|
||||
|
|
|
@ -2,26 +2,28 @@ Source: ceo
|
|||
Maintainer: Systems Committee <syscom@csclub.uwaterloo.ca>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Standards-Version: 4.3.0
|
||||
Standards-Version: 4.6.2
|
||||
Vcs-Git: https://git.csclub.uwaterloo.ca/public/pyceo.git
|
||||
Vcs-Browser: https://git.csclub.uwaterloo.ca/public/pyceo
|
||||
Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>
|
||||
Build-Depends: debhelper (>= 12.1.1),
|
||||
python3-dev (>= 3.7),
|
||||
python3-venv (>= 3.7),
|
||||
libkrb5-dev (>= 1.17),
|
||||
libpq-dev (>= 11.13),
|
||||
libaugeas0 (>= 1.11),
|
||||
scdoc (>= 1.9)
|
||||
Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>,
|
||||
Raymond Li <raymo@csclub.uwaterloo.ca>,
|
||||
Nathan <n4chung@csclub.uwaterloo.ca>,
|
||||
Edwin <e42zhang@csclub.uwaterloo.ca>
|
||||
Build-Depends: debhelper (>= 13),
|
||||
python3-dev (>= 3.9),
|
||||
python3-venv (>= 3.9),
|
||||
libkrb5-dev (>= 1.18),
|
||||
libpq-dev (>= 13.9),
|
||||
libaugeas0 (>= 1.12),
|
||||
scdoc (>= 1.11)
|
||||
|
||||
Package: ceo-common
|
||||
Architecture: amd64
|
||||
Depends: python3 (>= 3.7),
|
||||
krb5-user (>= 1.17),
|
||||
libkrb5-3 (>= 1.17),
|
||||
libpq5 (>= 11.13),
|
||||
libaugeas0 (>= 1.11),
|
||||
${python3:Depends},
|
||||
Depends: python3 (>= 3.9),
|
||||
krb5-user (>= 1.18),
|
||||
libkrb5-3 (>= 1.18),
|
||||
libpq5 (>= 13.9),
|
||||
libaugeas0 (>= 1.12),
|
||||
${misc:Depends}
|
||||
Description: CSC Electronic Office common files
|
||||
This package contains the common files for the CSC Electronic Office.
|
||||
|
@ -39,6 +41,7 @@ Package: ceod
|
|||
Architecture: amd64
|
||||
Replaces: ceo-daemon
|
||||
Conflicts: ceo-daemon
|
||||
Pre-Depends: ${misc:Pre-Depends}
|
||||
Depends: ceo-common (= ${source:Version}), openssl (>= 1.1.1), ${misc:Depends}
|
||||
Description: CSC Electronic Office daemon
|
||||
This package contains the daemon for the CSC Electronic Office.
|
||||
|
|
|
@ -5,7 +5,14 @@
|
|||
|
||||
override_dh_strip:
|
||||
|
||||
override_dh_strip_nondeterminism:
|
||||
|
||||
override_dh_shlibdeps:
|
||||
|
||||
override_dh_systemd_start:
|
||||
dh_systemd_start --no-start ceod.service
|
||||
override_dh_makeshlibs:
|
||||
|
||||
override_dh_dwz:
|
||||
|
||||
override_dh_fixperms:
|
||||
dh_fixperms
|
||||
chmod 600 $(CURDIR)/debian/ceod/etc/csc/ceod.ini
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
flake8==3.9.2
|
||||
setuptools==40.8.0
|
||||
wheel==0.36.2
|
||||
pytest==6.2.4
|
||||
flake8==6.1.0
|
||||
setuptools==68.0.0
|
||||
wheel==0.41.0
|
||||
pytest==7.4.0
|
||||
aiosmtpd==1.4.2
|
||||
aiohttp==3.7.4.post0
|
||||
aiohttp==3.8.5
|
||||
|
|
|
@ -1,39 +1,54 @@
|
|||
version: "3.6"
|
||||
|
||||
x-common: &common
|
||||
image: python:3.7-buster
|
||||
volumes:
|
||||
- .:$PWD:z
|
||||
environment:
|
||||
FLASK_APP: ceod.api
|
||||
FLASK_ENV: development
|
||||
working_dir: $PWD
|
||||
- ./.drone:/app/.drone:ro
|
||||
- ./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:
|
||||
FLASK_APP: ceod.api
|
||||
FLASK_DEBUG: "true"
|
||||
|
||||
services:
|
||||
auth1:
|
||||
<<: *common
|
||||
image: debian:buster
|
||||
image: ceo-auth1:bullseye
|
||||
hostname: auth1
|
||||
command: auth1
|
||||
|
||||
coffee:
|
||||
<<: *common
|
||||
<<: *ceod-common
|
||||
image: ceo-coffee:bullseye
|
||||
command: coffee
|
||||
hostname: coffee
|
||||
depends_on:
|
||||
- auth1
|
||||
|
||||
mail:
|
||||
<<: *common
|
||||
<<: *ceod-common
|
||||
command: mail
|
||||
hostname: mail
|
||||
depends_on:
|
||||
- auth1
|
||||
|
||||
phosphoric-acid:
|
||||
<<: *common
|
||||
<<: *ceod-common
|
||||
command: phosphoric-acid
|
||||
hostname: phosphoric-acid
|
||||
depends_on:
|
||||
|
@ -41,4 +56,8 @@ services:
|
|||
- coffee
|
||||
- mail
|
||||
|
||||
volumes:
|
||||
ceo-venv:
|
||||
external: true
|
||||
|
||||
# vim: expandtab sw=2 ts=2
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
#!/bin/sh -e
|
||||
#!/bin/bash
|
||||
|
||||
if ! [ -d venv ]; then
|
||||
echo "You need to create the virtualenv first!" >&2
|
||||
set -ex
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <host>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host="$1"
|
||||
[ -x ".drone/$host-setup.sh" ] && ".drone/$host-setup.sh"
|
||||
if ! [ -d venv ]; then
|
||||
echo "You need to mount the virtualenv" >&2
|
||||
exit 1
|
||||
fi
|
||||
. .drone/$host-setup.sh
|
||||
CONTAINER__setup
|
||||
|
||||
if [ "$host" = auth1 ]; then
|
||||
exec sleep infinity
|
||||
else
|
||||
. venv/bin/activate
|
||||
exec .drone/supervise.sh flask run -h 0.0.0.0 -p 9987
|
||||
exec .drone/supervise.sh venv/bin/flask run -h 0.0.0.0 -p 9987
|
||||
fi
|
||||
|
|
|
@ -61,9 +61,9 @@ paths:
|
|||
program:
|
||||
$ref: "#/components/schemas/Program"
|
||||
terms:
|
||||
$ref: "#/components/schemas/Terms"
|
||||
$ref: "#/components/schemas/TermsOrNumTerms"
|
||||
non_member_terms:
|
||||
$ref: "#/components/schemas/NonMemberTerms"
|
||||
$ref: "#/components/schemas/TermsOrNumTerms"
|
||||
forwarding_addresses:
|
||||
$ref: "#/components/schemas/ForwardingAddresses"
|
||||
responses:
|
||||
|
@ -161,17 +161,11 @@ paths:
|
|||
- type: object
|
||||
properties:
|
||||
terms:
|
||||
type: array
|
||||
description: Terms for which this user will be a member
|
||||
items:
|
||||
$ref: "#/components/schemas/Term"
|
||||
$ref: "#/components/schemas/TermsOrNumTerms"
|
||||
- type: object
|
||||
properties:
|
||||
non_member_terms:
|
||||
type: array
|
||||
description: Terms for which this user will be a club rep
|
||||
items:
|
||||
$ref: "#/components/schemas/Term"
|
||||
$ref: "#/components/schemas/TermsOrNumTerms"
|
||||
example: {"terms": ["f2021"]}
|
||||
responses:
|
||||
"200":
|
||||
|
@ -290,6 +284,26 @@ paths:
|
|||
$ref: "#/components/schemas/UID"
|
||||
"404":
|
||||
$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}:
|
||||
post:
|
||||
tags: ['groups']
|
||||
|
@ -383,11 +397,14 @@ paths:
|
|||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: array
|
||||
description: list of usernames
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
president: user0
|
||||
vice-president: user1
|
||||
sysadmin: user2
|
||||
president: ["user1"]
|
||||
vice-president: ["user2", "user3"]
|
||||
sysadmin: ["user4"]
|
||||
treasurer:
|
||||
post:
|
||||
tags: ['positions']
|
||||
|
@ -404,11 +421,18 @@ paths:
|
|||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
oneOf:
|
||||
- type: string
|
||||
description: username or comma-separated list of usernames
|
||||
- type: array
|
||||
description: list of usernames
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
president: user0
|
||||
vice-president: user1
|
||||
sysadmin: user2
|
||||
president: user1
|
||||
vice-president: user2, user3
|
||||
secretary: ["user4", "user5"]
|
||||
sysadmin: ["user6"]
|
||||
treasurer:
|
||||
responses:
|
||||
"200":
|
||||
|
@ -422,7 +446,7 @@ paths:
|
|||
{"status": "in progress", "operation": "update_positions_ldap"}
|
||||
{"status": "in progress", "operation": "update_exec_group_ldap"}
|
||||
{"status": "in progress", "operation": "subscribe_to_mailing_list"}
|
||||
{"status": "completed", "result": "OK"}
|
||||
{"status": "completed", "result": {"president": ["user1"],"vice-president": ["user2", "user3"],"secretary": ["user4". "user5"],"sysadmin": ["user6"]}}
|
||||
"400":
|
||||
description: Failed
|
||||
content:
|
||||
|
@ -883,14 +907,15 @@ components:
|
|||
example: MAT/Mathematics Computer Science
|
||||
Terms:
|
||||
type: array
|
||||
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
|
||||
description: List of terms
|
||||
items:
|
||||
$ref: "#/components/schemas/Term"
|
||||
TermsOrNumTerms:
|
||||
oneOf:
|
||||
- type: integer
|
||||
description: number of additional terms to add
|
||||
example: 1
|
||||
- $ref: "#/components/schemas/Terms"
|
||||
LoginShell:
|
||||
type: string
|
||||
description: Login shell
|
||||
|
@ -939,9 +964,14 @@ components:
|
|||
terms:
|
||||
$ref: "#/components/schemas/Terms"
|
||||
non_member_terms:
|
||||
$ref: "#/components/schemas/NonMemberTerms"
|
||||
$ref: "#/components/schemas/Terms"
|
||||
forwarding_addresses:
|
||||
$ref: "#/components/schemas/ForwardingAddresses"
|
||||
groups:
|
||||
type: array
|
||||
description: Groups for which this user is a member of
|
||||
items:
|
||||
$ref: "#/components/schemas/GroupCN"
|
||||
UWLDAPUser:
|
||||
type: object
|
||||
properties:
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -18,8 +18,11 @@ port = 9987
|
|||
|
||||
[positions]
|
||||
required = president,vice-president,sysadmin
|
||||
available = president,vice-president,treasurer,secretary,
|
||||
sysadmin,cro,librarian,imapd,webmaster,offsck
|
||||
available = president,vice-president,sysadmin,treasurer,
|
||||
secretary,cro,webmaster,offsck,ext-affairs-lead,
|
||||
marketing-lead,design-lead,events-lead,reps-lead,
|
||||
mods-lead,photography-lead,codey-bot-lead,other
|
||||
|
||||
|
||||
[mysql]
|
||||
host = caffeine
|
||||
|
|
11
etc/ceod.ini
11
etc/ceod.ini
|
@ -30,6 +30,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
|
|||
server_url = ldaps://uwldap.uwaterloo.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]
|
||||
min_id = 20001
|
||||
max_id = 29999
|
||||
|
@ -63,8 +67,10 @@ exec = exec,exec-moderators
|
|||
|
||||
[positions]
|
||||
required = president,vice-president,sysadmin
|
||||
available = president,vice-president,treasurer,secretary,
|
||||
sysadmin,cro,librarian,imapd,webmaster,offsck
|
||||
available = president,vice-president,sysadmin,treasurer,
|
||||
secretary,cro,webmaster,offsck,ext-affairs-lead,
|
||||
marketing-lead,design-lead,events-lead,reps-lead,
|
||||
mods-lead,photography-lead,codey-bot-lead,other
|
||||
|
||||
[mysql]
|
||||
# This is only used on the database_host.
|
||||
|
@ -97,6 +103,7 @@ members_domain = csclub.cloud
|
|||
k8s_members_domain = k8s.csclub.cloud
|
||||
ip_range_min = 172.19.134.10
|
||||
ip_range_max = 172.19.134.160
|
||||
reload_web_server_cmd = /root/bin/reload-nginx.sh
|
||||
|
||||
[k8s]
|
||||
members_clusterrole = csc-members-default
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
click==8.1.3
|
||||
cryptography==35.0.0
|
||||
Flask==2.1.2
|
||||
gssapi==1.6.14
|
||||
gunicorn==20.1.0
|
||||
click==8.1.6
|
||||
cryptography==41.0.2
|
||||
dnspython==2.5.0
|
||||
Flask==2.3.2
|
||||
gssapi==1.8.2
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
ldap3==2.9.1
|
||||
mysql-connector-python==8.0.26
|
||||
psycopg2==2.9.1
|
||||
mysql-connector-python==8.1.0
|
||||
psycopg2-binary==2.9.6
|
||||
python-augeas==1.1.0
|
||||
requests==2.26.0
|
||||
requests==2.31.0
|
||||
requests-gssapi==1.2.3
|
||||
urwid==2.1.2
|
||||
Werkzeug==2.1.2
|
||||
Werkzeug==2.3.6
|
||||
zope.component==5.0.1
|
||||
zope.interface==5.4.0
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
#!/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
|
|
@ -0,0 +1,16 @@
|
|||
#!/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
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Install pre-configured git hooks
|
||||
git config --local core.hooksPath .githooks/
|
|
@ -0,0 +1,18 @@
|
|||
#!/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
|
|
@ -17,6 +17,11 @@ def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg):
|
|||
'Congratulations! Your cloud account has been activated.\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"
|
||||
'\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.output == expected
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from click.testing import CliRunner
|
||||
from mysql.connector import connect
|
||||
|
@ -10,14 +11,24 @@ from ceo.cli import cli
|
|||
|
||||
|
||||
def mysql_attempt_connection(host, username, password):
|
||||
time.sleep(0.5)
|
||||
with connect(
|
||||
host=host,
|
||||
user=username,
|
||||
password=password,
|
||||
host=host,
|
||||
user=username,
|
||||
password=password,
|
||||
) as con, con.cursor() as cur:
|
||||
cur.execute("SHOW DATABASES")
|
||||
response = cur.fetchall()
|
||||
assert len(response) == 2
|
||||
found = False
|
||||
# Sometimes, when running the tests locally, I've observed a race condition
|
||||
# where another client can't "see" a database right after we create it.
|
||||
# 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):
|
||||
cur.execute("CREATE DATABASE new_db")
|
||||
|
@ -34,7 +45,7 @@ def test_mysql(cli_setup, cfg, ldap_user):
|
|||
|
||||
# create database for user
|
||||
result = runner.invoke(cli, ['mysql', 'create', username], input='y\n')
|
||||
print(result.output)
|
||||
#print(result.output) # noqa: E265
|
||||
assert result.exit_code == 0
|
||||
assert os.path.isfile(info_file_path)
|
||||
|
||||
|
|
|
@ -77,6 +77,27 @@ def test_groups(cli_setup, ldap_user):
|
|||
result = runner.invoke(cli, ['groups', 'delete', 'test_group_1'], input='y\n')
|
||||
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):
|
||||
runner = CliRunner()
|
||||
|
@ -117,8 +138,7 @@ def test_groups_multiple_members(cli_setup, new_user_gen):
|
|||
def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
|
||||
runner = CliRunner()
|
||||
# make sure auxiliary groups + mailing lists exist in ceod_test_local.ini
|
||||
create_group('syscom', 'Systems Committee')
|
||||
create_group('office', 'Office')
|
||||
create_group('adm', 'Administrators')
|
||||
create_group('staff', 'Staff')
|
||||
|
||||
runner = CliRunner()
|
||||
|
@ -131,7 +151,7 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
|
|||
"Add user to auxiliary groups... Done\n"
|
||||
"Subscribe user to auxiliary mailing lists... Done\n"
|
||||
"Transaction successfully completed.\n"
|
||||
f"Added {ldap_user.uid} to syscom, office, staff\n"
|
||||
f"Added {ldap_user.uid} to syscom, adm, staff\n"
|
||||
f"Subscribed {ldap_user.uid} to syscom@csclub.internal, syscom-alerts@csclub.internal\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
@ -147,7 +167,7 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
|
|||
"Remove user from auxiliary groups... Done\n"
|
||||
"Unsubscribe user from auxiliary mailing lists... Done\n"
|
||||
"Transaction successfully completed.\n"
|
||||
f"Removed {ldap_user.uid} from syscom, office, staff\n"
|
||||
f"Removed {ldap_user.uid} from syscom, adm, staff\n"
|
||||
f"Unsubscribed {ldap_user.uid} from syscom@csclub.internal, syscom-alerts@csclub.internal\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
@ -167,6 +187,5 @@ def test_groups_with_auxiliary_groups_and_mailing_lists(cli_setup, ldap_user):
|
|||
assert result.exit_code == 0
|
||||
assert 'Unsubscribed' not in result.output
|
||||
|
||||
delete_group('syscom')
|
||||
delete_group('office')
|
||||
delete_group('adm')
|
||||
delete_group('staff')
|
||||
|
|
|
@ -4,6 +4,7 @@ import shutil
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from click.testing import CliRunner
|
||||
import ldap3
|
||||
|
||||
from ceo.cli import cli
|
||||
from ceo_common.model import Term
|
||||
|
@ -25,8 +26,9 @@ def test_members_get(cli_setup, ldap_user):
|
|||
f"home directory: {ldap_user.home_directory}\n"
|
||||
f"is a club: {ldap_user.is_club()}\n"
|
||||
f"is a club rep: {ldap_user.is_club_rep}\n"
|
||||
"forwarding addresses: \n"
|
||||
f"forwarding addresses:\n"
|
||||
f"member terms: {','.join(ldap_user.terms)}\n"
|
||||
f"groups:\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.output == expected
|
||||
|
@ -134,19 +136,20 @@ def test_members_renew(cli_setup, ldap_user, g_admin_ctx):
|
|||
assert result.output == expected
|
||||
|
||||
|
||||
def test_members_pwreset(cli_setup, ldap_user, krb_user):
|
||||
def test_members_pwreset(cli_setup, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ['members', 'pwreset', ldap_user.uid], input='y\n')
|
||||
cli, ['members', 'pwreset', uid], input='y\n')
|
||||
expected_pat = re.compile((
|
||||
f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n"
|
||||
f"^Are you sure you want to reset {uid}'s password\\? \\[y/N\\]: y\n"
|
||||
"New password: \\S+\n$"
|
||||
), re.MULTILINE)
|
||||
assert result.exit_code == 0
|
||||
assert expected_pat.match(result.output) is not None
|
||||
|
||||
|
||||
def test_members_expire(cli_setup, app_process, ldap_user, syscom_group):
|
||||
def test_members_expire(cli_setup, app_process, ldap_user):
|
||||
runner = CliRunner()
|
||||
|
||||
try:
|
||||
|
@ -172,12 +175,17 @@ def test_members_expire(cli_setup, app_process, ldap_user, syscom_group):
|
|||
restore_datetime_in_app_process(app_process)
|
||||
|
||||
|
||||
def test_members_remindexpire(cli_setup, app_process, ldap_user):
|
||||
def test_members_remindexpire(cfg, cli_setup, app_process, ldap_conn, ldap_user):
|
||||
runner = CliRunner()
|
||||
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:
|
||||
test_date = (term + 1).to_datetime()
|
||||
set_datetime_in_app_process(app_process, test_date)
|
||||
result = runner.invoke(cli, ['members', 'remindexpire', '--dry-run'])
|
||||
assert result.exit_code == 0
|
||||
|
@ -200,3 +208,7 @@ def test_members_remindexpire(cli_setup, app_process, ldap_user):
|
|||
assert result.output == "No members are pending expiration.\n"
|
||||
finally:
|
||||
restore_datetime_in_app_process(app_process)
|
||||
|
||||
ldap_conn.modify(
|
||||
f'uid=ctdalek,{base_dn}',
|
||||
{'term': [(ldap3.MODIFY_DELETE, [str(term + 1)])]})
|
||||
|
|
|
@ -39,22 +39,29 @@ def test_positions(cli_setup, g_admin_ctx):
|
|||
assert result.exit_code == 0
|
||||
assert result.output == '''
|
||||
The positions will be updated:
|
||||
president: test_0
|
||||
vice-president: test_1
|
||||
sysadmin: test_2
|
||||
secretary: test_3
|
||||
webmaster: test_4
|
||||
treasurer:
|
||||
cro:
|
||||
librarian:
|
||||
imapd:
|
||||
offsck:
|
||||
president: test_0
|
||||
vice-president: test_1
|
||||
sysadmin: test_2
|
||||
secretary: test_3
|
||||
webmaster: test_4
|
||||
treasurer:
|
||||
cro:
|
||||
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:] # noqa: W291
|
||||
'''[1:]
|
||||
|
||||
result = runner.invoke(cli, ['positions', 'get'])
|
||||
assert result.exit_code == 0
|
||||
|
@ -71,3 +78,78 @@ webmaster: test_4
|
|||
for user in users:
|
||||
user.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()
|
||||
|
|
|
@ -5,6 +5,7 @@ from ceo_common.model import Term
|
|||
from tests.utils import (
|
||||
create_php_file_for_club,
|
||||
reset_disable_club_conf,
|
||||
create_website_config_for_club,
|
||||
set_datetime_in_app_process,
|
||||
restore_datetime_in_app_process,
|
||||
)
|
||||
|
@ -15,10 +16,12 @@ def test_disable_club_sites(
|
|||
new_club_gen, new_user_gen, g_admin_ctx, ldap_srv_session,
|
||||
):
|
||||
runner = CliRunner()
|
||||
sites_available_dir = webhosting_srv.sites_available_dir
|
||||
term = Term.current()
|
||||
clubs_home = cfg.get('clubs_home')
|
||||
with new_club_gen() as group, new_user_gen() as user:
|
||||
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())])
|
||||
group.add_member(user.uid)
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
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)
|
|
@ -14,8 +14,10 @@ port = 9987
|
|||
|
||||
[positions]
|
||||
required = president,vice-president,sysadmin
|
||||
available = president,vice-president,treasurer,secretary,
|
||||
sysadmin,cro,librarian,imapd,webmaster,offsck
|
||||
available = president,vice-president,sysadmin,treasurer,
|
||||
secretary,cro,webmaster,offsck,ext-affairs-lead,
|
||||
marketing-lead,design-lead,events-lead,reps-lead,
|
||||
mods-lead,photography-lead,codey-bot-lead,other
|
||||
|
||||
[mysql]
|
||||
host = coffee
|
||||
|
|
|
@ -5,8 +5,8 @@ from mysql.connector import connect
|
|||
from mysql.connector.errors import ProgrammingError
|
||||
|
||||
|
||||
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
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_user, krb_user):
|
|||
user.remove_from_ldap()
|
||||
|
||||
|
||||
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
|
|
@ -4,8 +4,8 @@ from ceod.model import User
|
|||
from psycopg2 import connect, OperationalError, ProgrammingError
|
||||
|
||||
|
||||
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
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_user, krb_user):
|
|||
user.remove_from_ldap()
|
||||
|
||||
|
||||
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_user, krb_user):
|
||||
uid = ldap_user.uid
|
||||
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_and_krb_user):
|
||||
uid = ldap_and_krb_user.uid
|
||||
|
||||
with g_admin_ctx():
|
||||
user = User(uid='someone_else', cn='Some Name', given_name='Some',
|
||||
|
|
|
@ -2,6 +2,7 @@ import ldap3
|
|||
import pytest
|
||||
|
||||
from ceod.model import Group
|
||||
from tests.conftest_ceod_api import expect_successful_txn
|
||||
|
||||
|
||||
def test_api_group_not_found(client):
|
||||
|
@ -9,20 +10,28 @@ def test_api_group_not_found(client):
|
|||
assert status == 404
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def create_group_resp(client):
|
||||
def create_group(client, cn: str, description: str):
|
||||
status, data = client.post('/api/groups', json={
|
||||
'cn': 'test_group1',
|
||||
'description': 'Test Group One',
|
||||
'cn': cn,
|
||||
'description': description,
|
||||
})
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
yield status, data
|
||||
status, data = client.delete('/api/groups/test_group1')
|
||||
return status, data
|
||||
|
||||
|
||||
def delete_group(client, cn: str):
|
||||
status, data = client.delete(f'/api/groups/{cn}')
|
||||
assert status == 200
|
||||
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')
|
||||
def create_group_result(create_group_resp):
|
||||
# convenience method
|
||||
|
@ -111,6 +120,47 @@ def test_api_add_member_to_group(client, create_group_result, ldap_user):
|
|||
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):
|
||||
# Make sure that syscom has auxiliary mailing lists and groups
|
||||
# defined in ceod_test_local.ini.
|
||||
|
@ -125,7 +175,8 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
|
|||
group_names = ['syscom'] + aux_groups
|
||||
groups = []
|
||||
with g_admin_ctx():
|
||||
for group_name in group_names:
|
||||
# the syscom group should already exist, since we need it for auth
|
||||
for group_name in aux_groups:
|
||||
group = Group(
|
||||
cn=group_name,
|
||||
gid_number=min_uid,
|
||||
|
@ -161,4 +212,29 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
|
|||
|
||||
with g_admin_ctx():
|
||||
for group in groups:
|
||||
group.remove_from_ldap()
|
||||
if group.cn != 'syscom':
|
||||
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))
|
||||
|
|
|
@ -72,7 +72,6 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
|
|||
"mail_local_addresses": ["test1@csclub.internal"],
|
||||
"forwarding_addresses": ['test1@uwaterloo.internal'],
|
||||
"password": "krb5",
|
||||
"shadowExpire": None,
|
||||
}},
|
||||
]
|
||||
assert data == expected
|
||||
|
@ -84,6 +83,25 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
|
|||
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):
|
||||
min_uid = cfg.get('members_min_id')
|
||||
_, data = client.post('/api/members', json={
|
||||
|
@ -93,6 +111,7 @@ def test_api_next_uid(cfg, client, create_user_result):
|
|||
'sn': 'Two',
|
||||
'program': 'Math',
|
||||
'terms': ['s2021'],
|
||||
'forwarding_addresses': ['test2@uwaterloo.internal']
|
||||
})
|
||||
assert data[-1]['status'] == 'completed'
|
||||
result = data[-1]['result']
|
||||
|
@ -103,12 +122,60 @@ def test_api_next_uid(cfg, client, create_user_result):
|
|||
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):
|
||||
old_data = create_user_result.copy()
|
||||
uid = old_data['uid']
|
||||
del old_data['password']
|
||||
|
||||
status, data = client.get(f'/api/members/{uid}')
|
||||
del data['groups']
|
||||
assert status == 200
|
||||
assert data == old_data
|
||||
|
||||
|
@ -203,6 +270,20 @@ def test_api_renew_user(cfg, client, create_user_result, ldap_conn):
|
|||
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):
|
||||
uid = create_user_result['uid']
|
||||
with patch.object(ceod.utils, 'gen_password') as gen_password_mock:
|
||||
|
@ -219,8 +300,8 @@ def test_api_reset_password(client, create_user_result):
|
|||
def test_authz_check(client, create_user_result):
|
||||
# non-staff members may not create users
|
||||
status, data = client.post('/api/members', json={
|
||||
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
|
||||
'sn': 'One', 'terms': ['s2021'],
|
||||
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One',
|
||||
'terms': ['s2021'], 'forwarding_addresses': ['test1@uwaterloo.internal']
|
||||
}, principal='regular1')
|
||||
assert status == 403
|
||||
|
||||
|
@ -230,18 +311,19 @@ def test_authz_check(client, create_user_result):
|
|||
del old_data['password']
|
||||
del old_data['forwarding_addresses']
|
||||
_, data = client.get(f'/api/members/{uid}', principal='regular1')
|
||||
del data['groups']
|
||||
assert data == old_data
|
||||
|
||||
# If we're syscom but we don't pass credentials, the request should fail
|
||||
_, data = client.post('/api/members', json={
|
||||
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
|
||||
'sn': 'One', 'terms': ['s2021'],
|
||||
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test', 'sn': 'One',
|
||||
'terms': ['s2021'], 'forwarding_addresses': ['test1@uwaterloo.internal']
|
||||
}, principal='ctdalek', delegate=False)
|
||||
assert data[-1]['status'] == 'aborted'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('term_attr', ['terms', 'non_member_terms'])
|
||||
def test_expire(client, new_user, term_attr, syscom_group, ldap_conn):
|
||||
def test_expire(client, new_user, term_attr, ldap_conn):
|
||||
assert new_user.shadowExpire is None
|
||||
current_term = Term.current()
|
||||
start_of_current_term = current_term.to_datetime()
|
||||
|
@ -281,17 +363,18 @@ def test_expire(client, new_user, term_attr, syscom_group, ldap_conn):
|
|||
|
||||
status, data = client.post('/api/members/expire?dry_run=yes')
|
||||
assert status == 200
|
||||
print(data)
|
||||
assert (data == [uid]) == should_expire
|
||||
|
||||
_, user = client.get(f'/api/members/{uid}')
|
||||
assert user['shadowExpire'] is None
|
||||
assert 'shadow_expire' not in user
|
||||
|
||||
status, data = client.post('/api/members/expire')
|
||||
assert status == 200
|
||||
assert (data == [uid]) == should_expire
|
||||
|
||||
_, user = client.get(f'/api/members/{uid}')
|
||||
assert (user['shadowExpire'] is not None) == should_expire
|
||||
assert (user.get('shadow_expire') == 1) == should_expire
|
||||
|
||||
if not should_expire:
|
||||
continue
|
||||
|
@ -301,20 +384,18 @@ def test_expire(client, new_user, term_attr, syscom_group, ldap_conn):
|
|||
assert status == 200
|
||||
|
||||
_, user = client.get(f'/api/members/{uid}')
|
||||
assert user['shadowExpire'] is None
|
||||
assert 'shadow_expire' not in user
|
||||
reset_terms()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('in_syscom', [True, False])
|
||||
def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_conn, in_syscom):
|
||||
def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom):
|
||||
uid = new_user.uid
|
||||
start_of_current_term = Term.current().to_datetime()
|
||||
if in_syscom:
|
||||
group_dn = new_user.ldap_srv.group_cn_to_dn('syscom')
|
||||
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)
|
||||
with patch.object(ceo_common.utils, 'get_current_datetime') as datetime_mock:
|
||||
datetime_mock.return_value = start_of_current_term + datetime.timedelta(days=160)
|
||||
|
@ -324,9 +405,12 @@ def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_
|
|||
assert data == []
|
||||
else:
|
||||
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):
|
||||
def test_office_member(cfg, client, office_user):
|
||||
admin_principal = cfg.get('ldap_admin_principal')
|
||||
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||
if os.path.isfile(ccache_file):
|
||||
|
@ -367,11 +451,17 @@ def test_office_member(cfg, client):
|
|||
assert status == 200
|
||||
|
||||
|
||||
def test_membership_renewal_reminder(client, mock_mail_server):
|
||||
def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn):
|
||||
uids = ['test3', 'test4']
|
||||
# fast-forward by one term so that we don't clash with the other users
|
||||
# created by other tests
|
||||
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:
|
||||
datetime_mock.return_value = term.to_datetime()
|
||||
for uid in uids:
|
||||
|
@ -433,3 +523,7 @@ def test_membership_renewal_reminder(client, mock_mail_server):
|
|||
status, _ = client.delete(f'/api/members/{uid}')
|
||||
assert status == 200
|
||||
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)])]})
|
||||
|
|
|
@ -7,8 +7,8 @@ def test_get_positions(client, ldap_user, g_admin_ctx):
|
|||
status, data = client.get('/api/positions')
|
||||
assert status == 200
|
||||
expected = {
|
||||
'president': ldap_user.uid,
|
||||
'treasurer': ldap_user.uid,
|
||||
'president': [ldap_user.uid],
|
||||
'treasurer': [ldap_user.uid],
|
||||
}
|
||||
assert data == expected
|
||||
|
||||
|
@ -20,7 +20,7 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
|
|||
|
||||
users = []
|
||||
with g_admin_ctx():
|
||||
for uid in ['test_1', 'test_2', 'test_3', 'test_4']:
|
||||
for uid in ['test1', 'test2', 'test3', 'test4']:
|
||||
user = User(uid=uid, cn='Some Name', given_name='Some', sn='Name',
|
||||
terms=['s2021'])
|
||||
user.add_to_ldap()
|
||||
|
@ -31,23 +31,23 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
|
|||
try:
|
||||
# missing required position
|
||||
status, _ = client.post('/api/positions', json={
|
||||
'vice-president': 'test_1',
|
||||
'vice-president': 'test1',
|
||||
})
|
||||
assert status == 400
|
||||
|
||||
# non-existent position
|
||||
status, _ = client.post('/api/positions', json={
|
||||
'president': 'test_1',
|
||||
'vice-president': 'test_2',
|
||||
'sysadmin': 'test_3',
|
||||
'no-such-position': 'test_3',
|
||||
'president': 'test1',
|
||||
'vice-president': 'test2',
|
||||
'sysadmin': 'test3',
|
||||
'no-such-position': 'test3',
|
||||
})
|
||||
assert status == 400
|
||||
|
||||
status, data = client.post('/api/positions', json={
|
||||
'president': 'test_1',
|
||||
'vice-president': 'test_2',
|
||||
'sysadmin': 'test_3',
|
||||
'president': 'test1',
|
||||
'vice-president': 'test2',
|
||||
'sysadmin': 'test3',
|
||||
})
|
||||
assert status == 200
|
||||
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": "subscribe_to_mailing_lists"},
|
||||
{"status": "completed", "result": {
|
||||
"president": "test_1",
|
||||
"vice-president": "test_2",
|
||||
"sysadmin": "test_3",
|
||||
"president": ["test1"],
|
||||
"vice-president": ["test2"],
|
||||
"sysadmin": ["test3"],
|
||||
}},
|
||||
]
|
||||
assert data == expected
|
||||
# make sure execs were added to exec group
|
||||
status, data = client.get('/api/groups/exec')
|
||||
assert status == 200
|
||||
expected = ['test_1', 'test_2', 'test_3']
|
||||
expected = ['test1', 'test2', 'test3']
|
||||
assert sorted([item['uid'] for item in data['members']]) == expected
|
||||
# make sure execs were subscribed to mailing lists
|
||||
addresses = [f'{uid}@{base_domain}' for uid in expected]
|
||||
|
@ -72,20 +72,55 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
|
|||
assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses
|
||||
|
||||
_, data = client.post('/api/positions', json={
|
||||
'president': 'test_1',
|
||||
'vice-president': 'test_2',
|
||||
'sysadmin': 'test_2',
|
||||
'treasurer': 'test_4',
|
||||
'president': 'test1',
|
||||
'vice-president': 'test2',
|
||||
'sysadmin': 'test2',
|
||||
'treasurer': 'test4',
|
||||
})
|
||||
assert data[-1]['status'] == 'completed'
|
||||
# make sure old exec was removed from group
|
||||
expected = ['test_1', 'test_2', 'test_4']
|
||||
expected = ['test1', 'test2', 'test4']
|
||||
_, data = client.get('/api/groups/exec')
|
||||
assert sorted([item['uid'] for item in data['members']]) == expected
|
||||
# make sure old exec was removed from mailing lists
|
||||
addresses = [f'{uid}@{base_domain}' for uid in expected]
|
||||
for mailing_list in mailing_lists:
|
||||
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:
|
||||
with g_admin_ctx():
|
||||
for user in users:
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue