Compare commits

..

1 Commits

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

View File

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

View File

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

View File

@ -4,128 +4,103 @@ set -ex
. .drone/common.sh
# 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
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
}
# 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
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
# KERBEROS
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
cp .drone/krb5.conf /etc/krb5.conf
cp .drone/kdc.conf /etc/krb5kdc.conf
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm -f /var/lib/krb5kdc/*
echo -e 'krb5\nkrb5' | krb5_newrealm
service krb5-kdc start
service krb5-admin-server start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin.local
addpol -minlength 4 default
addprinc -pw krb5 sysadmin/admin
addprinc -randkey ceod/admin
addprinc -pw krb5 ctdalek
addprinc -pw krb5 exec1
addprinc -pw krb5 regular1
addprinc -pw krb5 office1
addprinc -randkey host/auth1.csclub.internal
addprinc -randkey ldap/auth1.csclub.internal
ktadd host/auth1.csclub.internal
ktadd ldap/auth1.csclub.internal
EOF
# 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
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
}
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
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
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 &
}
done
# 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

View File

@ -4,28 +4,25 @@ set -ex
. .drone/common.sh
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
IMAGE__setup() {
IMAGE__ceod_setup
apt install --no-install-recommends -y default-mysql-server postgresql
apt install --no-install-recommends -y default-mysql-server postgresql
# 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
# 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
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
EOF
# POSTGRESQL
service postgresql stop
local POSTGRES_DIR=/etc/postgresql/*/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# POSTGRESQL
service postgresql stop
POSTGRES_DIR=/etc/postgresql/11/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres localhost md5
@ -39,29 +36,19 @@ 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
service mariadb stop || true
service postgresql stop || true
}
if [ -z "$CI" ]; then
auth_setup coffee
fi
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 &
}
# sync with phosphoric-acid
nc -l 0.0.0.0 9000 &

View File

@ -1,109 +1,58 @@
# TODO: fix Drone
chmod 1777 /tmp
# don't resolve container names to *real* CSC machines
sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /tmp/resolv.conf
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
# netcat is used for synchronization between the containers
export DEBIAN_FRONTEND=noninteractive
# 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
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
}
IMAGE__common_setup() {
apt update
# netcat is used for synchronization between the containers
apt install -y netcat-openbsd
IMAGE__auth_setup
}
IMAGE__ceod_setup() {
IMAGE__common_setup
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
}
CONTAINER__fix_resolv_conf() {
# don't resolve container names to *real* CSC machines
sed -E 's/([[:alnum:]-]+\.)*uwaterloo\.ca//g' /etc/resolv.conf > /tmp/resolv.conf
# remove empty 'search' lines, if we created them
sed -E -i '/^search[[:space:]]*$/d' /tmp/resolv.conf
# also remove the 'rotate' option, since this can cause the Docker DNS server
# to be circumvented
sed -E -i '/^options.*\brotate/d' /tmp/resolv.conf
# we can't replace /etc/resolv.conf using 'mv' because it's mounted into the container
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
}
CONTAINER__auth_setup() {
local hostname=$1
sync_with auth1
service nslcd start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
}
CONTAINER__ceod_setup() {
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
}
# Common utility functions
fi
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
getent hosts $1 | cut -d' ' -f1
}
add_fqdn_to_hosts() {
local ip_addr=$1
local hostname=$2
ip_addr=$1
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
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
@ -114,3 +63,45 @@ sync_with() {
done
test $synced = true
}
auth_setup() {
hostname=$1
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
cp .drone/ldap.conf /etc/ldap/ldap.conf
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
if [ $hostname = phosphoric-acid ]; then
sync_port=9000
elif [ $hostname = coffee ]; then
sync_port=9001
else
sync_port=9002
fi
sync_with auth1 $sync_port
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
if [ $hostname = phosphoric-acid ]; then
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey ceod/admin
ktadd ceod/admin
EOF
fi
service nslcd start
}

View File

@ -186,28 +186,3 @@ objectClass: group
objectClass: posixGroup
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

View File

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

View File

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

View File

@ -4,44 +4,29 @@ set -ex
. .drone/common.sh
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
}
# 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__setup_userdirs() {
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
auth_setup phosphoric-acid
# create directories for users
for user in ctdalek regular1 exec1; do
mkdir -p /users/$user
chown $user:$user /users/$user
done
}
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
IMAGE__setup() {
IMAGE__ceod_setup
# git is required by the ClubWebHostingService
apt install --no-install-recommends -y git
}
# create directories for users
for user in ctdalek regular1 exec1; do
mkdir -p /users/$user
chown $user:$user /users/$user
done
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
}
sync_with coffee
if [ -z "$CI" ]; then
sync_with mail
fi

View File

@ -7,7 +7,6 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/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
@ -41,11 +40,6 @@ 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

View File

@ -106,18 +106,3 @@ 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

View File

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

4
.gitignore vendored
View File

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

View File

@ -9,16 +9,11 @@ Make sure your GPG key is in /srv/debian/gpg on potassium-benzoate. See
[here](https://wiki.csclub.uwaterloo.ca/Debian_Repository#Step_1:_Add_to_Uploaders)
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":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
podman run -it --name pyceo-packaging -v "$PWD":"$PWD" -w "$PWD" debian:bullseye 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
@ -29,39 +24,22 @@ 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 (or `dpkg-source --commit`).
Update VERSION.txt to the next version, and do a git commit.
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
Now run `dch -i` and edit the changelog.
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
```sh
# (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
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC
```
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 (run this after uploading, ie. **do NOT run this if you just finished building**):
To clean the packages:
```sh
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
```
@ -69,30 +47,28 @@ 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 .. # within the container, generated files are in the parent directory of your git repo
cd ..
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
```
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.
```
# on "HOST" machine
scp pyceo.tar.gz mannitol:~
scp pyceo.tar.gz mannitol:~/
```
SSH into that machine and extract the tarball into a separate directory:
```
ssh mannitol
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
mkdir pyceo-parent
mv pyceo.tar.gz pyceo-parent/
cd pyceo-parent
tar zxvf pyceo.tar.gz
```
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage).
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy.
Now upload the package to potassium-benzoate:
```
@ -102,21 +78,7 @@ 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? :')

View File

@ -10,29 +10,17 @@ overview of its architecture.
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
## Development
### Podman
### Docker
If you are not modifying code related to email or Mailman, then you may use
Podman containers instead, which are much easier to work with than the VM.
Docker containers instead, which are much easier to work with than the VM.
If you are using Podman, make sure to set the `DOCKER_HOST` environment variable
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:
First, make sure you create the virtualenv:
```sh
scripts/build-all-images.sh
docker run --rm -v "$PWD:$PWD" -w "$PWD" python:3.7-buster sh -c 'apt update && apt install -y libaugeas0 && python -m venv venv && . venv/bin/activate && pip install -r requirements.txt -r dev-requirements.txt'
```
Then bring up the containers:
```sh
docker-compose up -d
docker-compose up -d # or without -d to run in the foreground
```
This will create some containers with the bare minimum necessary for ceod to
run, and start ceod on each of phosphoric-acid, mail, and coffee container.
@ -195,12 +183,12 @@ replaced by coffee).
To run ceod on a single host (as root, since the app needs to read the keytab):
```sh
export FLASK_APP=ceod.api
export FLASK_DEBUG=true
export FLASK_ENV=development
flask run -h 0.0.0.0 -p 9987
```
Sometimes changes you make in the source code don't show up while Flask
is running. Stop the flask app (Ctrl-C), run `scripts/clear_cache.sh`, then
is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
restart the app.
## Interacting with the application
@ -226,23 +214,14 @@ curl -V
```
Your should see 'SPNEGO' in the 'Features' section.
Here's an example of making a request to add a user (in the Docker container):
Here's an example of making a request to an endpoint which writes to LDAP:
```sh
# If you're root, switch to the ctdalek user first
su ctdalek
# Get a Kerberos TGT (password is krb5)
# Get a Kerberos TGT first
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

View File

@ -1 +1 @@
1.0.31
1.0.22

View File

@ -1,4 +1,5 @@
import os
import socket
import sys
from zope import component
@ -8,7 +9,6 @@ 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,7 +19,8 @@ def register_services():
if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG']
else:
if is_in_development():
# This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
config_file = './tests/ceo_dev.ini'
else:
config_file = '/etc/csc/ceo.ini'

View File

@ -28,11 +28,6 @@ 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)

View File

@ -149,15 +149,3 @@ 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)

View File

@ -3,16 +3,13 @@ from typing import Dict
import click
from zope import component
from ceo_common.utils import validate_username
from ..term_utils import get_terms_for_renewal_for_user
from ..term_utils import get_terms_for_new_user, get_terms_for_renewal
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
@ -39,11 +36,6 @@ 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:
@ -56,7 +48,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 and result.get('mail_local_addresses'):
if forwarding_address is None:
forwarding_address = result['mail_local_addresses'][0]
if cn is None:
cn = click.prompt('Full name')
@ -163,7 +155,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_for_user(username, num_terms, clubrep)
terms = get_terms_for_renewal(username, num_terms, clubrep)
if clubrep:
body = {'non_member_terms': terms}

View File

@ -16,22 +16,13 @@ def positions():
def get():
resp = http_get('/api/positions')
result = handle_sync_response(resp)
print_colon_kv([
(position, ', '.join(usernames))
for position, usernames in result.items()
])
print_colon_kv(result.items())
@positions.command(short_help='Update positions')
def set(**kwargs):
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()
}
body = {k.replace('_', '-'): v for k, v in kwargs.items()}
print_body = {k: v or '' 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)

View File

@ -1,9 +1,10 @@
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
@ -52,6 +53,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 is_in_development():
if not socket.getfqdn().endswith('.csclub.internal'):
click.echo('This command may only be called during development.')
raise Abort()

View File

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

View File

@ -3,9 +3,9 @@ from threading import Thread
from ...utils import http_get
from .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 = get_terms_for_new_user(self.model.num_terms)
new_terms = term_utils.get_terms_for_new_user(self.model.num_terms)
if self.model.membership_type == 'club_rep':
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') or [''])[0]
self.model.forwarding_address = data.get('mail_local_addresses', [''])[0]
self.app.run_in_main_loop(self._on_lookup_user_success)

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ class SetPositionsController(Controller):
body = {}
for pos, field in self.view.position_fields.items():
if field.edit_text != '':
body[pos] = field.edit_text.replace(' ', '').split(',')
body[pos] = field.edit_text
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, usernames in positions.items():
self.model.positions[pos] = ','.join(usernames)
for pos, username in positions.items():
self.model.positions[pos] = username
def target():
self.view.flash_text.set_text('')

View File

@ -8,7 +8,6 @@ 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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ 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

View File

@ -25,7 +25,6 @@ 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()
@ -59,10 +58,6 @@ 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)

View File

@ -53,10 +53,6 @@ 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

View File

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

View File

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

View File

@ -16,8 +16,6 @@ from .AddGroupView import AddGroupView
from .AddGroupConfirmationView import AddGroupConfirmationView
from .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

View File

@ -5,15 +5,8 @@ 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',
}

View File

@ -87,9 +87,6 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
maxlen = max(len(key) for key, val in pairs)
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
@ -139,8 +136,6 @@ 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

View File

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

View File

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

View File

@ -33,11 +33,6 @@ class ILDAPService(Interface):
A new UID and GID will be generated and returned in the new user.
"""
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."""

View File

@ -1,23 +1,20 @@
import typing
from typing import List, Optional
from typing import List, Union
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) -> Optional['UWLDAPRecord']:
def get_user(username: str):
"""
Return the LDAP record for the given user, or
None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
"""
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
"""
Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in

View File

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

View File

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

View File

@ -6,7 +6,7 @@ from requests_gssapi import HTTPSPNEGOAuth
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IConfig, IHTTPClient, IKerberosService
from ceo_common.interfaces import IConfig, IHTTPClient
@implementer(IHTTPClient)
@ -40,18 +40,10 @@ class HTTPClient:
'opportunistic_auth': True,
'target_name': gssapi.Name('ceod/' + host),
}
if flask.has_request_context():
if flask.has_request_context() and 'client_token' in g:
# This is reached when we are the server and the client has
# forwarded their credentials to us.
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)
spnego_kwargs['creds'] = gssapi.Credentials(token=g.client_token)
elif delegate:
# This is reached when we are the client and we want to
# forward our credentials to the server.

View File

@ -1,5 +1,4 @@
import datetime
from typing import List, Union
import ceo_common.utils as utils
@ -82,39 +81,3 @@ 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))

View File

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

View File

@ -1,78 +1,7 @@
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')

View File

@ -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, IADLDAPService
IContainerRegistryService, IClubWebHostingService
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, ADLDAPService
ContainerRegistryService, ClubWebHostingService
from ceod.db import MySQLService, PostgreSQLService
@ -36,15 +36,6 @@ 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
@ -62,6 +53,15 @@ 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('DEBUG') and 'CEOD_CONFIG' not in os.environ:
if app.config.get('ENV') == 'development' and 'CEOD_CONFIG' not in os.environ:
with importlib.resources.path('tests', 'ceod_dev.ini') as p:
config_file = p.__fspath__()
else:
@ -112,13 +112,9 @@ def register_services(app):
mail_srv = MailService()
component.provideUtility(mail_srv, IMailService)
# 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)
# UWLDAPService
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
# ClubWebHostingService
if hostname == cfg.get('ceod_webhosting_host'):

View File

@ -1,12 +1,9 @@
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, requires_admin_creds, \
requires_authentication_no_realm, user_is_in_group
create_streaming_response, development_only
from ceo_common.interfaces import ILDAPService
from ceo_common.utils import fuzzy_result, fuzzy_match
from ceod.transactions.groups import (
AddGroupTransaction,
AddMemberToGroupTransaction,
@ -14,8 +11,6 @@ from ceod.transactions.groups import (
DeleteGroupTransaction,
)
from heapq import heappushpop, nlargest
bp = Blueprint('groups', __name__)
@ -37,42 +32,9 @@ 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'])
@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
@authz_restrict_to_syscom
def add_member_to_group(group_name, username):
subscribe_to_lists = is_truthy(
request.args.get('subscribe_to_lists', 'true')
)
@ -85,13 +47,8 @@ def add_member_to_group(auth_username: str, group_name: str, username: str):
@bp.route('/<group_name>/members/<username>', methods=['DELETE'])
@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
@authz_restrict_to_syscom
def remove_member_from_group(group_name, username):
unsubscribe_from_lists = is_truthy(
request.args.get('unsubscribe_from_lists', 'true')
)

View File

@ -1,15 +1,13 @@
from flask import Blueprint, request
from flask import Blueprint, g, request
from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
user_is_in_group, requires_authentication_no_realm, requires_admin_creds, \
user_is_in_group, requires_authentication_no_realm, \
create_streaming_response, development_only, is_truthy
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,
@ -22,36 +20,20 @@ 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')
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']:
for attr in ['uid', 'cn', 'given_name', 'sn']:
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')
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}")
# We need to use the admin creds here because office members may not
# directly create new LDAP records.
g.need_admin_creds = True
txn = AddMemberTransaction(
uid=body['uid'],
@ -61,7 +43,7 @@ def create_user():
program=body.get('program'),
terms=terms,
non_member_terms=non_member_terms,
forwarding_addresses=body['forwarding_addresses'],
forwarding_addresses=body.get('forwarding_addresses'),
)
return create_streaming_response(txn)
@ -79,9 +61,7 @@ def get_user(auth_user: str, username: str):
get_forwarding_addresses = True
ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username)
user_dict = user.to_dict(get_forwarding_addresses)
user_dict['groups'] = ldap_srv.get_groups_for_user(username)
return user_dict
return user.to_dict(get_forwarding_addresses)
@bp.route('/<username>', methods=['PATCH'])
@ -101,48 +81,40 @@ 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:
logger.debug(f'{user.uid} is already subscribed to {member_list}')
pass
if terms:
logger.info(f"Renewing member {username} for terms {terms}")
user.add_terms(terms)
if body.get('terms'):
user.add_terms(body['terms'])
unexpire(user)
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)
return {'terms_added': body['terms']}
elif body.get('non_member_terms'):
user.add_non_member_terms(body['non_member_terms'])
unexpire(user)
return {'non_member_terms_added': non_member_terms}
return {'non_member_terms_added': body['non_member_terms']}
else:
raise BadRequest('Must specify either terms or non-member terms')
@ -158,12 +130,9 @@ def reset_user_password(username: str):
@bp.route('/<username>', methods=['DELETE'])
@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)
@ -180,13 +149,11 @@ 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:
logger.debug(f'{member.uid} is already unsubscribed from {member_list}')
pass
return jsonify([member.uid for member in members])

View File

@ -2,7 +2,6 @@ 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
@ -16,9 +15,7 @@ def get_positions():
positions = {}
for user in ldap_srv.get_users_with_positions():
for position in user.positions:
if position not in positions:
positions[position] = []
positions[position].append(user.uid)
positions[position] = user.uid
return positions
@ -32,31 +29,23 @@ def update_positions():
required = cfg.get('positions_required')
available = cfg.get('positions_available')
# 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')
# remove falsy values
body = {
positions: username for positions, username in body.items()
if username
}
# 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():
for position in body.keys():
if position not in available:
raise BadRequest(f'unknown position: {position}')
return {
'error': f'unknown position: {position}'
}, 400
for position in required:
if position not in position_to_usernames:
raise BadRequest(f'missing required position: {position}')
if position not in body:
return {
'error': f'missing required position: {position}'
}, 400
txn = UpdateMemberPositionsTransaction(position_to_usernames)
txn = UpdateMemberPositionsTransaction(body)
return create_streaming_response(txn)

View File

@ -1,9 +1,12 @@
import functools
import grp
import json
import os
import pwd
import traceback
from typing import Callable, List
from flask import current_app, g, stream_with_context
from flask import current_app, stream_with_context
from zope import component
from .spnego import requires_authentication
@ -37,29 +40,9 @@ def requires_authentication_no_realm(f: Callable) -> Callable:
return wrapper
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 user_is_in_group(user: str, group: str) -> bool:
"""Returns True if `user` is in `group`, False otherwise."""
return user in grp.getgrnam(group).gr_mem
def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable:
@ -68,6 +51,8 @@ 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):
@ -77,14 +62,15 @@ 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)
ldap_srv = component.getUtility(ILDAPService)
for group_name in ldap_srv.get_groups_for_user(username):
if group_name in allowed_groups:
for gid in os.getgrouplist(username, pwd.getpwnam(username).pw_gid):
if gid in allowed_group_ids:
return f(*args, **kwargs)
logger.debug(
f"User '{username}' denied since they are not in one of {allowed_groups}"
)
return {'error': f'You must be in one of {allowed_groups}'}, 403
return {
'error': f'You must be in one of {allowed_groups}'
}, 403
return wrapper
@ -144,7 +130,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('DEBUG') or \
if current_app.config.get('ENV') == 'development' or \
current_app.config.get('TESTING'):
return f(*args, **kwargs)
return {

View File

@ -3,25 +3,15 @@ from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
from ceo_common.logger_factory import logger_factory
from ceo_common.interfaces import IUWLDAPService, ILDAPService
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',

View File

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

View File

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

View File

@ -62,7 +62,6 @@ 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',
})
@ -77,8 +76,7 @@ class CloudStackService:
for account in d['account']
}
def delete_account(self, username: str, account_id: str):
logger.info(f'Deleting CloudStack account for {username}')
def delete_account(self, account_id: str):
url = self._create_url({
'command': 'deleteAccount',
'id': account_id,

View File

@ -84,19 +84,16 @@ 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)
# 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'})
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)
def commit(self):
if not self.made_at_least_one_change:
@ -115,13 +112,12 @@ class ClubWebHostingService:
directive_paths = self.aug.match(f'/files/etc/apache2/sites-available/{filename}/VirtualHost/directive')
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
@ -161,20 +157,12 @@ class ClubWebHostingService:
def _site_uses_php(self, club_name: str) -> bool:
www = f'{self.clubs_home}/{club_name}/www'
if not os.path.isdir(www):
return False
try:
if os.path.isdir(www):
# We're just going to look one level deep; that should be good enough.
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
for filename in os.listdir(www):
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)
@ -236,7 +224,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[club_name]['email']
address = clubs_info['email']
if address is None:
clubs_who_were_not_notified.add(club_name)
continue

View File

@ -7,9 +7,6 @@ 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)
@ -73,7 +70,6 @@ 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:

View File

@ -11,9 +11,6 @@ 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)
@ -103,7 +100,6 @@ 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)

View File

@ -98,13 +98,6 @@ 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 []
@ -112,14 +105,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 sorted([
return [
{
'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()
@ -316,14 +309,12 @@ 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=member)'
filter = '(objectClass=*)'
conn = self._get_ldap_conn()
conn.search(
self.ldap_users_base, filter, attributes=['uid', 'program'])
@ -338,17 +329,12 @@ 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]
if csc_programs[i] != uw_programs[i] and (
uw_programs[i] not in (None, 'expired', 'orphaned')
)
]
if dry_run:
return users_to_change
@ -360,11 +346,7 @@ class LDAPService:
return users_to_change
def _get_club_uids(self, conn: ldap3.Connection) -> List[str]:
conn.extend.standard.paged_search(self.ldap_users_base,
'(objectClass=club)',
attributes=['uid'],
paged_size=50,
generator=False)
conn.search(self.ldap_users_base, '(objectClass=club)', attributes=['uid'])
return [entry.uid.value for entry in conn.entries]
def get_clubs(self) -> List[IGroup]:

View File

@ -1,11 +1,11 @@
from typing import List, Optional
from typing import Union, List
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) -> Optional[UWLDAPRecord]:
def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
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[Optional[str]]:
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
programs = [None] * len(usernames)
user_indices = {uid: i for i, uid in enumerate(usernames)}

View File

@ -86,6 +86,7 @@ class User:
'is_club': self.is_club(),
'is_club_rep': self.is_club_rep,
'program': self.program or 'Unknown',
'shadowExpire': self.shadowExpire,
}
if self.sn and self.given_name:
data['sn'] = self.sn
@ -102,8 +103,6 @@ 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:
@ -170,7 +169,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', [None])[0],
shadowExpire=attrs.get('shadowExpire'),
ldap3_entry=entry,
)

View File

@ -4,7 +4,7 @@ import os
import re
import shutil
import subprocess
from typing import List, Dict, Tuple, Union
from typing import List, Dict, Tuple
import jinja2
from zope import component
@ -53,7 +53,6 @@ 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'
@ -83,12 +82,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: Union[List[str], str], **kwargs):
subprocess.run(args, check=True, **kwargs)
def _run(self, args: List[str]):
subprocess.run(args, check=True)
def _reload_web_server(self):
logger.debug('Reloading NGINX')
self._run(self.reload_web_server_cmd, shell=True)
self._run(['systemctl', 'reload', 'nginx'])
def is_valid_domain(self, username: str, domain: str) -> bool:
if VALID_DOMAIN_RE.match(domain) is None:
@ -151,7 +150,7 @@ class VHostManager:
self.acme_sh, '--install-cert', '-d', domain,
'--key-file', key_path,
'--fullchain-file', cert_path,
'--reloadcmd', self.reload_web_server_cmd,
'--reloadcmd', 'systemctl reload nginx',
])
def _delete_cert(self, domain: str, cert_path: str, key_path: str):

View File

@ -1,4 +1,3 @@
from .ADLDAPService import ADLDAPService
from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService
from .KerberosService import KerberosService
@ -6,6 +5,7 @@ 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

View File

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

2
debian/ceod.postinst vendored Executable file
View File

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

76
debian/changelog vendored
View File

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

2
debian/compat vendored
View File

@ -1 +1 @@
13
10

33
debian/control vendored
View File

@ -2,28 +2,26 @@ Source: ceo
Maintainer: Systems Committee <syscom@csclub.uwaterloo.ca>
Section: admin
Priority: optional
Standards-Version: 4.6.2
Standards-Version: 4.3.0
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>,
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)
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)
Package: ceo-common
Architecture: amd64
Depends: python3 (>= 3.9),
krb5-user (>= 1.18),
libkrb5-3 (>= 1.18),
libpq5 (>= 13.9),
libaugeas0 (>= 1.12),
Depends: python3 (>= 3.7),
krb5-user (>= 1.17),
libkrb5-3 (>= 1.17),
libpq5 (>= 11.13),
libaugeas0 (>= 1.11),
${python3:Depends},
${misc:Depends}
Description: CSC Electronic Office common files
This package contains the common files for the CSC Electronic Office.
@ -41,7 +39,6 @@ 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.

11
debian/rules vendored
View File

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

View File

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

View File

@ -1,54 +1,39 @@
version: "3.6"
x-common: &common
image: python:3.7-buster
volumes:
- ./.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
- .:$PWD:z
environment:
FLASK_APP: ceod.api
FLASK_DEBUG: "true"
FLASK_ENV: development
working_dir: $PWD
entrypoint:
- ./docker-entrypoint.sh
services:
auth1:
<<: *common
image: ceo-auth1:bullseye
image: debian:buster
hostname: auth1
command: auth1
coffee:
<<: *ceod-common
image: ceo-coffee:bullseye
<<: *common
command: coffee
hostname: coffee
depends_on:
- auth1
mail:
<<: *ceod-common
<<: *common
command: mail
hostname: mail
depends_on:
- auth1
phosphoric-acid:
<<: *ceod-common
<<: *common
command: phosphoric-acid
hostname: phosphoric-acid
depends_on:
@ -56,8 +41,4 @@ services:
- coffee
- mail
volumes:
ceo-venv:
external: true
# vim: expandtab sw=2 ts=2

View File

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

View File

@ -61,9 +61,9 @@ paths:
program:
$ref: "#/components/schemas/Program"
terms:
$ref: "#/components/schemas/TermsOrNumTerms"
$ref: "#/components/schemas/Terms"
non_member_terms:
$ref: "#/components/schemas/TermsOrNumTerms"
$ref: "#/components/schemas/NonMemberTerms"
forwarding_addresses:
$ref: "#/components/schemas/ForwardingAddresses"
responses:
@ -161,11 +161,17 @@ paths:
- type: object
properties:
terms:
$ref: "#/components/schemas/TermsOrNumTerms"
type: array
description: Terms for which this user will be a member
items:
$ref: "#/components/schemas/Term"
- type: object
properties:
non_member_terms:
$ref: "#/components/schemas/TermsOrNumTerms"
type: array
description: Terms for which this user will be a club rep
items:
$ref: "#/components/schemas/Term"
example: {"terms": ["f2021"]}
responses:
"200":
@ -284,26 +290,6 @@ 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']
@ -397,14 +383,11 @@ paths:
schema:
type: object
additionalProperties:
type: array
description: list of usernames
items:
type: string
type: string
example:
president: ["user1"]
vice-president: ["user2", "user3"]
sysadmin: ["user4"]
president: user0
vice-president: user1
sysadmin: user2
treasurer:
post:
tags: ['positions']
@ -421,18 +404,11 @@ paths:
schema:
type: object
additionalProperties:
oneOf:
- type: string
description: username or comma-separated list of usernames
- type: array
description: list of usernames
items:
type: string
type: string
example:
president: user1
vice-president: user2, user3
secretary: ["user4", "user5"]
sysadmin: ["user6"]
president: user0
vice-president: user1
sysadmin: user2
treasurer:
responses:
"200":
@ -446,7 +422,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": {"president": ["user1"],"vice-president": ["user2", "user3"],"secretary": ["user4". "user5"],"sysadmin": ["user6"]}}
{"status": "completed", "result": "OK"}
"400":
description: Failed
content:
@ -907,15 +883,14 @@ components:
example: MAT/Mathematics Computer Science
Terms:
type: array
description: List of terms
description: Terms for which this user was a member
items:
$ref: "#/components/schemas/Term"
NonMemberTerms:
type: array
description: Terms for which this user was a club rep
items:
$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
@ -964,14 +939,9 @@ components:
terms:
$ref: "#/components/schemas/Terms"
non_member_terms:
$ref: "#/components/schemas/Terms"
$ref: "#/components/schemas/NonMemberTerms"
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

View File

@ -18,11 +18,8 @@ port = 9987
[positions]
required = president,vice-president,sysadmin
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
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
host = caffeine

View File

@ -30,10 +30,6 @@ 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
@ -67,10 +63,8 @@ exec = exec,exec-moderators
[positions]
required = president,vice-president,sysadmin
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
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
# This is only used on the database_host.
@ -103,7 +97,6 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,11 +17,6 @@ def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg):
'Congratulations! Your cloud account has been activated.\n'
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

View File

@ -1,6 +1,5 @@
import os
import shutil
import time
from click.testing import CliRunner
from mysql.connector import connect
@ -11,24 +10,14 @@ 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:
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
cur.execute("SHOW DATABASES")
response = cur.fetchall()
assert len(response) == 2
with pytest.raises(ProgrammingError):
cur.execute("CREATE DATABASE new_db")
@ -45,7 +34,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) # noqa: E265
print(result.output)
assert result.exit_code == 0
assert os.path.isfile(info_file_path)

View File

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

View File

@ -4,7 +4,6 @@ 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
@ -26,9 +25,8 @@ 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"
f"forwarding addresses:\n"
"forwarding addresses: \n"
f"member terms: {','.join(ldap_user.terms)}\n"
f"groups:\n"
)
assert result.exit_code == 0
assert result.output == expected
@ -136,20 +134,19 @@ def test_members_renew(cli_setup, ldap_user, g_admin_ctx):
assert result.output == expected
def test_members_pwreset(cli_setup, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
def test_members_pwreset(cli_setup, ldap_user, krb_user):
runner = CliRunner()
result = runner.invoke(
cli, ['members', 'pwreset', uid], input='y\n')
cli, ['members', 'pwreset', ldap_user.uid], input='y\n')
expected_pat = re.compile((
f"^Are you sure you want to reset {uid}'s password\\? \\[y/N\\]: y\n"
f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n"
"New password: \\S+\n$"
), 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):
def test_members_expire(cli_setup, app_process, ldap_user, syscom_group):
runner = CliRunner()
try:
@ -175,17 +172,12 @@ def test_members_expire(cli_setup, app_process, ldap_user):
restore_datetime_in_app_process(app_process)
def test_members_remindexpire(cfg, cli_setup, app_process, ldap_conn, ldap_user):
def test_members_remindexpire(cli_setup, app_process, ldap_user):
runner = CliRunner()
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
@ -208,7 +200,3 @@ def test_members_remindexpire(cfg, cli_setup, app_process, ldap_conn, ldap_user)
assert result.output == "No members are pending expiration.\n"
finally:
restore_datetime_in_app_process(app_process)
ldap_conn.modify(
f'uid=ctdalek,{base_dn}',
{'term': [(ldap3.MODIFY_DELETE, [str(term + 1)])]})

View File

@ -39,29 +39,22 @@ def test_positions(cli_setup, g_admin_ctx):
assert result.exit_code == 0
assert result.output == '''
The positions will be updated:
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:
president: test_0
vice-president: test_1
sysadmin: test_2
secretary: test_3
webmaster: test_4
treasurer:
cro:
librarian:
imapd:
offsck:
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:]
'''[1:] # noqa: W291
result = runner.invoke(cli, ['positions', 'get'])
assert result.exit_code == 0
@ -78,78 +71,3 @@ 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()

View File

@ -5,7 +5,6 @@ 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,
)
@ -16,12 +15,10 @@ 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)

View File

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

View File

@ -14,10 +14,8 @@ port = 9987
[positions]
required = president,vice-president,sysadmin
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
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
host = coffee

View File

@ -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_and_krb_user):
uid = ldap_and_krb_user.uid
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_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_and_krb_user):
user.remove_from_ldap()
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -4,8 +4,8 @@ from ceod.model import User
from psycopg2 import connect, OperationalError, ProgrammingError
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_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_and_krb_user):
user.remove_from_ldap()
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -2,7 +2,6 @@ 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):
@ -10,28 +9,20 @@ def test_api_group_not_found(client):
assert status == 404
def create_group(client, cn: str, description: str):
@pytest.fixture(scope='module')
def create_group_resp(client):
status, data = client.post('/api/groups', json={
'cn': cn,
'description': description,
'cn': 'test_group1',
'description': 'Test Group One',
})
assert status == 200
assert data[-1]['status'] == 'completed'
return status, data
def delete_group(client, cn: str):
status, data = client.delete(f'/api/groups/{cn}')
yield status, data
status, data = client.delete('/api/groups/test_group1')
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
@ -120,47 +111,6 @@ 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.
@ -175,8 +125,7 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
group_names = ['syscom'] + aux_groups
groups = []
with g_admin_ctx():
# the syscom group should already exist, since we need it for auth
for group_name in aux_groups:
for group_name in group_names:
group = Group(
cn=group_name,
gid_number=min_uid,
@ -212,29 +161,4 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
with g_admin_ctx():
for group in groups:
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))
group.remove_from_ldap()

View File

@ -72,6 +72,7 @@ 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
@ -83,25 +84,6 @@ 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={
@ -111,7 +93,6 @@ 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']
@ -122,60 +103,12 @@ 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
@ -270,20 +203,6 @@ 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:
@ -300,8 +219,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'], 'forwarding_addresses': ['test1@uwaterloo.internal']
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
'sn': 'One', 'terms': ['s2021'],
}, principal='regular1')
assert status == 403
@ -311,19 +230,18 @@ 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'], 'forwarding_addresses': ['test1@uwaterloo.internal']
'uid': 'test1', 'cn': 'Test One', 'given_name': 'Test',
'sn': 'One', 'terms': ['s2021'],
}, 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, ldap_conn):
def test_expire(client, new_user, term_attr, syscom_group, ldap_conn):
assert new_user.shadowExpire is None
current_term = Term.current()
start_of_current_term = current_term.to_datetime()
@ -363,18 +281,17 @@ def test_expire(client, new_user, term_attr, ldap_conn):
status, data = client.post('/api/members/expire?dry_run=yes')
assert status == 200
print(data)
assert (data == [uid]) == should_expire
_, user = client.get(f'/api/members/{uid}')
assert 'shadow_expire' not in user
assert user['shadowExpire'] is None
status, data = client.post('/api/members/expire')
assert status == 200
assert (data == [uid]) == should_expire
_, user = client.get(f'/api/members/{uid}')
assert (user.get('shadow_expire') == 1) == should_expire
assert (user['shadowExpire'] is not None) == should_expire
if not should_expire:
continue
@ -384,18 +301,20 @@ def test_expire(client, new_user, term_attr, ldap_conn):
assert status == 200
_, user = client.get(f'/api/members/{uid}')
assert 'shadow_expire' not in user
assert user['shadowExpire'] is None
reset_terms()
@pytest.mark.parametrize('in_syscom', [True, False])
def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom):
def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_conn, in_syscom):
uid = new_user.uid
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)
@ -405,12 +324,9 @@ def test_expire_syscom_member(client, new_user, ldap_conn, in_syscom):
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, office_user):
def test_office_member(cfg, client):
admin_principal = cfg.get('ldap_admin_principal')
ccache_file = cfg.get('ldap_admin_principal_ccache')
if os.path.isfile(ccache_file):
@ -451,17 +367,11 @@ def test_office_member(cfg, client, office_user):
assert status == 200
def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn):
def test_membership_renewal_reminder(client, mock_mail_server):
uids = ['test3', 'test4']
# 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:
@ -523,7 +433,3 @@ def test_membership_renewal_reminder(cfg, client, mock_mail_server, ldap_conn):
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)])]})

View File

@ -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 ['test1', 'test2', 'test3', 'test4']:
for uid in ['test_1', 'test_2', 'test_3', 'test_4']:
user = User(uid=uid, cn='Some Name', given_name='Some', sn='Name',
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': 'test1',
'vice-president': 'test_1',
})
assert status == 400
# non-existent position
status, _ = client.post('/api/positions', json={
'president': 'test1',
'vice-president': 'test2',
'sysadmin': 'test3',
'no-such-position': 'test3',
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_3',
'no-such-position': 'test_3',
})
assert status == 400
status, data = client.post('/api/positions', json={
'president': 'test1',
'vice-president': 'test2',
'sysadmin': 'test3',
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_3',
})
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": ["test1"],
"vice-president": ["test2"],
"sysadmin": ["test3"],
"president": "test_1",
"vice-president": "test_2",
"sysadmin": "test_3",
}},
]
assert data == expected
# make sure execs were added to exec group
status, data = client.get('/api/groups/exec')
assert status == 200
expected = ['test1', 'test2', 'test3']
expected = ['test_1', 'test_2', 'test_3']
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,55 +72,20 @@ 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': 'test1',
'vice-president': 'test2',
'sysadmin': 'test2',
'treasurer': 'test4',
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_2',
'treasurer': 'test_4',
})
assert data[-1]['status'] == 'completed'
# make sure old exec was removed from group
expected = ['test1', 'test2', 'test4']
expected = ['test_1', 'test_2', 'test_4']
_, data = client.get('/api/groups/exec')
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