Compare commits

...

13 Commits

Author SHA1 Message Date
Max Erenberg dbe1ef4d09 use list to represent multiple users for a given position
continuous-integration/drone/pr Build is passing Details
2023-01-23 02:15:13 -05:00
Max Erenberg d6ce40037d Merge branch 'master' into feature-59 2023-01-22 21:07:35 -05:00
Max Erenberg f84965c8e1 reload all NGINX servers after adding a vhost (#90)
continuous-integration/drone/push Build is passing Details
Currently, only the NGINX server on biloba is reloaded after adding a new vhost or renewing an SSL certificate. The NGINX server on chamomile should also be reloaded, since chamomile is a warm standby for biloba.

This PR adds a new config option in ceod.ini to specify the shell command to reload the web servers.

Reviewed-on: #90
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:20:55 -05:00
Max Erenberg 4394c4e277 use bullseye for base container (#91)
continuous-integration/drone/push Build is passing Details
All of the machines running ceod are on bullseye, so we don't need to support buster anymore.

Reviewed-on: #91
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:15:40 -05:00
Jonathan Leung b507c56136 Show groups in member for API, CLI and TUI (#82)
continuous-integration/drone/push Build is passing Details
Closes #69.

Tests are failing locally with many `assert os.geteuid() == 0` errors even on the master branch. I will add tests after I figure this out.

Reviewed-on: #82
Co-authored-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
Co-committed-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
2022-11-26 20:09:05 -05:00
Max Erenberg c0c9736593 Use the admin creds in the HTTPClient when necessary (#85)
continuous-integration/drone/push Build is passing Details
Currently, ceod uses the Kerberos credentials of the client when making requests to other services. This requires the client to send delegated credentials. Unfortunately the NPM krb5 package appears to be unable to perform delegation. So we will use the admin credentials instead (when appropriate).

Reviewed-on: #85
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-11-06 15:23:27 -05:00
Max Erenberg 1e452d10ce Assume program is Alumni if UWLDAP is missing data (#84)
continuous-integration/drone/push Build is passing Details
This PR sets 'program=Alumni' for members who either do not have an 'ou' attribute in UWLDAP, or who do not have a UWLDAP entry at all.

Reviewed-on: #84
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
2022-11-01 21:02:05 -04:00
Max Erenberg 6a1fa81b82 merenber signs the packages
continuous-integration/drone/push Build is passing Details
Something went wrong when e42zhang tried to upload the packages to the
mirror. reprepro kept on complaining that no distribution would accept
the new package. I modified the changelog, re-signed and re-uploaded
the packages, and that worked, so I'm still not sure what the problem
was.
2022-10-23 22:04:32 -04:00
Max Erenberg 6df1f4d459 Revert "Simplify packaging"
This reverts commit b4a1373559.
2022-10-23 22:00:48 -04:00
Edwin 2cf9e25b59 More fixes
continuous-integration/drone/push Build is passing Details
2022-10-23 21:16:58 -04:00
Edwin 9ff3d850c9 Release 1.0.24 2022-10-23 19:50:38 -04:00
Edwin b4a1373559 Simplify packaging 2022-10-23 19:50:06 -04:00
Raymond Li dceb5d6572
Revert "#63: Add positions to CEO (#79)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3b7c89c925.
2022-10-13 18:12:36 -04:00
35 changed files with 2617 additions and 676 deletions

View File

@ -5,7 +5,7 @@ name: default
steps:
# use the step name to mock out the gethostname() call in our tests
- name: phosphoric-acid
image: python:3.7-buster
image: python:3.9-bullseye
# unfortunately we have to do everything in one step because there's no
# way to share system packages between steps
commands:
@ -25,12 +25,12 @@ steps:
services:
- name: auth1
image: debian:buster
image: debian:bullseye
commands:
- .drone/auth1-setup.sh
- sleep infinity
- name: coffee
image: debian:buster
image: debian:bullseye
commands:
- .drone/coffee-setup.sh
- sleep infinity

View File

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

View File

@ -11,9 +11,9 @@ add_fqdn_to_hosts $(get_ip_addr auth1) auth1
apt install --no-install-recommends -y default-mysql-server postgresql
# MYSQL
service mysql stop
sed -E -i 's/^(bind-address[[:space:]]+= 127.0.0.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mysql start
service mariadb stop
sed -E -i 's/^(bind-address[[:space:]]+= 127\.0\.0\.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mariadb start
cat <<EOF | mysql
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
@ -21,7 +21,7 @@ EOF
# POSTGRESQL
service postgresql stop
POSTGRES_DIR=/etc/postgresql/11/main
POSTGRES_DIR=/etc/postgresql/*/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer

View File

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

View File

@ -9,6 +9,8 @@ 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):
@ -58,7 +60,7 @@ podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
(Replace `/home/max/repos` by the directory in the container with the tarball.)
Now upload the tarball to a CSC machine, e.g.
```
scp pyceo.tar.gz mannitol:~/
scp pyceo.tar.gz mannitol:~
```
SSH into that machine and extract the tarball into a separate directory:
```

View File

@ -16,7 +16,7 @@ Docker containers instead, which are much easier to work with than the VM.
First, make sure you create the virtualenv:
```sh
docker run --rm -v "$PWD:$PWD:z" -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'
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye 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

View File

@ -1 +1 @@
1.0.23
1.0.24

View File

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

View File

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

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

View File

@ -9,12 +9,4 @@ position_names = {
'imapd': "IMAPD",
'webmaster': "Web Master",
'offsck': "Office Manager",
'ext-affairs-lead': "External Affairs Lead",
'marketing-lead': "Marketing Lead",
'design-lead': "Design Lead",
'events-lead': "Events Lead",
'reps-lead': "Reps Lead",
'mods-lead': "Mods Lead",
'photography-lead': "Photography Lead",
'other': "Other",
}

View File

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

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

View File

@ -71,7 +71,9 @@ def get_user(auth_user: str, username: str):
get_forwarding_addresses = True
ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username)
return user.to_dict(get_forwarding_addresses)
user_dict = user.to_dict(get_forwarding_addresses)
user_dict['groups'] = ldap_srv.get_groups_for_user(username)
return user_dict
@bp.route('/<username>', methods=['PATCH'])

View File

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

View File

@ -103,7 +103,7 @@ class LDAPService:
conn.search(self.ldap_groups_base,
f'(uniqueMember={self.uid_to_dn(username)})',
attributes=['cn'])
return [entry.cn.value for entry in conn.entries]
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:
@ -316,12 +316,12 @@ class LDAPService:
self,
dry_run: bool = False,
members: Union[List[str], None] = None,
uwldap_batch_size: int = 10,
uwldap_batch_size: int = 100,
):
if members:
filter = '(|' + ''.join([f'(uid={uid})' for uid in members]) + ')'
else:
filter = '(objectClass=*)'
filter = '(objectClass=member)'
conn = self._get_ldap_conn()
conn.search(
self.ldap_users_base, filter, attributes=['uid', 'program'])
@ -336,12 +336,17 @@ class LDAPService:
batch_uids = uids[i:i + uwldap_batch_size]
batch_uw_programs = uwldap_srv.get_programs_for_users(batch_uids)
uw_programs.extend(batch_uw_programs)
# uw_programs[i] will be None if the 'ou' attribute was not
# present in UWLDAP, or if no UWLDAP entry was found at all
for i, uw_program in enumerate(uw_programs):
if uw_program in (None, 'expired', 'orphaned'):
# If the UWLDAP record is orphaned, nonexistent, or missing
# data, assume that the member graduated
uw_programs[i] = 'Alumni'
users_to_change = [
(uids[i], csc_programs[i], uw_programs[i])
for i in range(len(uids))
if csc_programs[i] != uw_programs[i] and (
uw_programs[i] not in (None, 'expired', 'orphaned')
)
if csc_programs[i] != uw_programs[i]
]
if dry_run:
return users_to_change

View File

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

View File

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

13
debian/changelog vendored
View File

@ -1,3 +1,16 @@
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.

3
debian/control vendored
View File

@ -6,7 +6,8 @@ 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>
Raymond Li <raymo@csclub.uwaterloo.ca>,
Edwin <e42zhang@csclub.uwaterloo.ca>
Build-Depends: debhelper (>= 12.1.1),
python3-dev (>= 3.7),
python3-venv (>= 3.7),

View File

@ -1,9 +1,11 @@
version: "3.6"
x-common: &common
image: python:3.7-buster
image: python:3.9-bullseye
volumes:
- .:$PWD:z
security_opt:
- label:disable
environment:
FLASK_APP: ceod.api
FLASK_ENV: development
@ -14,7 +16,7 @@ x-common: &common
services:
auth1:
<<: *common
image: debian:buster
image: debian:bullseye
hostname: auth1
command: auth1

View File

@ -61,9 +61,9 @@ paths:
program:
$ref: "#/components/schemas/Program"
terms:
$ref: "#/components/schemas/Terms"
$ref: "#/components/schemas/TermsOrNumTerms"
non_member_terms:
$ref: "#/components/schemas/NonMemberTerms"
$ref: "#/components/schemas/TermsOrNumTerms"
forwarding_addresses:
$ref: "#/components/schemas/ForwardingAddresses"
responses:
@ -161,17 +161,11 @@ paths:
- type: object
properties:
terms:
type: array
description: Terms for which this user will be a member
items:
$ref: "#/components/schemas/Term"
$ref: "#/components/schemas/TermsOrNumTerms"
- type: object
properties:
non_member_terms:
type: array
description: Terms for which this user will be a club rep
items:
$ref: "#/components/schemas/Term"
$ref: "#/components/schemas/TermsOrNumTerms"
example: {"terms": ["f2021"]}
responses:
"200":
@ -383,11 +377,14 @@ paths:
schema:
type: object
additionalProperties:
type: string
type: array
description: list of usernames
items:
type: string
example:
president: user0
vice-president: user1
sysadmin: user2
president: ["user1"]
vice-president: ["user2", "user3"]
sysadmin: ["user4"]
treasurer:
post:
tags: ['positions']
@ -404,11 +401,18 @@ paths:
schema:
type: object
additionalProperties:
type: string
oneOf:
- type: string
description: username or comma-separated list of usernames
- type: array
description: list of usernames
items:
type: string
example:
president: user0
vice-president: user1
sysadmin: user2
president: user1
vice-president: user2, user3
secretary: ["user4", "user5"]
sysadmin: ["user6"]
treasurer:
responses:
"200":
@ -422,7 +426,7 @@ paths:
{"status": "in progress", "operation": "update_positions_ldap"}
{"status": "in progress", "operation": "update_exec_group_ldap"}
{"status": "in progress", "operation": "subscribe_to_mailing_list"}
{"status": "completed", "result": "OK"}
{"status": "completed", "result": {"president": ["user1"],"vice-president": ["user2", "user3"],"secretary": ["user4". "user5"],"sysadmin": ["user6"]}}
"400":
description: Failed
content:
@ -883,14 +887,15 @@ components:
example: MAT/Mathematics Computer Science
Terms:
type: array
description: Terms for which this user was a member
items:
$ref: "#/components/schemas/Term"
NonMemberTerms:
type: array
description: Terms for which this user was a club rep
description: List of terms
items:
$ref: "#/components/schemas/Term"
TermsOrNumTerms:
oneOf:
- type: integer
description: number of additional terms to add
example: 1
- $ref: "#/components/schemas/Terms"
LoginShell:
type: string
description: Login shell
@ -939,9 +944,14 @@ components:
terms:
$ref: "#/components/schemas/Terms"
non_member_terms:
$ref: "#/components/schemas/NonMemberTerms"
$ref: "#/components/schemas/Terms"
forwarding_addresses:
$ref: "#/components/schemas/ForwardingAddresses"
groups:
type: array
description: Groups for which this user is a member of
items:
$ref: "#/components/schemas/GroupCN"
UWLDAPUser:
type: object
properties:

File diff suppressed because one or more lines are too long

View File

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

View File

@ -64,9 +64,7 @@ exec = exec,exec-moderators
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck,
ext-affairs-lead,marketing-lead,design-lead,events-lead,
reps-lead,mods-lead,photography-lead,other
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
# This is only used on the database_host.
@ -99,6 +97,7 @@ members_domain = csclub.cloud
k8s_members_domain = k8s.csclub.cloud
ip_range_min = 172.19.134.10
ip_range_max = 172.19.134.160
reload_web_server_cmd = /root/bin/reload-nginx.sh
[k8s]
members_clusterrole = csc-members-default

View File

@ -6,7 +6,7 @@ gunicorn==20.1.0
Jinja2==3.1.2
ldap3==2.9.1
mysql-connector-python==8.0.26
psycopg2==2.9.1
psycopg2-binary==2.9.1
python-augeas==1.1.0
requests==2.26.0
requests-gssapi==1.2.3

View File

@ -1,5 +1,6 @@
import os
import shutil
import time
from click.testing import CliRunner
from mysql.connector import connect
@ -10,10 +11,15 @@ from ceo.cli import cli
def mysql_attempt_connection(host, username, password):
# 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 (MariaDB
# version: 10.5.18).
time.sleep(0.05)
with connect(
host=host,
user=username,
password=password,
host=host,
user=username,
password=password,
) as con, con.cursor() as cur:
cur.execute("SHOW DATABASES")
response = cur.fetchall()
@ -34,7 +40,7 @@ def test_mysql(cli_setup, cfg, ldap_user):
# create database for user
result = runner.invoke(cli, ['mysql', 'create', username], input='y\n')
print(result.output)
#print(result.output) # noqa: E265
assert result.exit_code == 0
assert os.path.isfile(info_file_path)

View File

@ -26,8 +26,9 @@ def test_members_get(cli_setup, ldap_user):
f"home directory: {ldap_user.home_directory}\n"
f"is a club: {ldap_user.is_club()}\n"
f"is a club rep: {ldap_user.is_club_rep}\n"
"forwarding addresses: \n"
f"forwarding addresses:\n"
f"member terms: {','.join(ldap_user.terms)}\n"
f"groups:\n"
)
assert result.exit_code == 0
assert result.output == expected

View File

@ -44,17 +44,17 @@ vice-president: test_1
sysadmin: test_2
secretary: test_3
webmaster: test_4
treasurer:
cro:
librarian:
imapd:
offsck:
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:] # noqa: W291
'''[1:]
result = runner.invoke(cli, ['positions', 'get'])
assert result.exit_code == 0
@ -71,3 +71,71 @@ 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:
librarian:
imapd:
webmaster:
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:]
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

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

View File

@ -127,6 +127,7 @@ def test_api_get_user(cfg, client, create_user_result):
del old_data['password']
status, data = client.get(f'/api/members/{uid}')
del data['groups']
assert status == 200
assert data == old_data
@ -262,6 +263,7 @@ 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

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 ['test_1', 'test_2', 'test_3', 'test_4']:
for uid in ['test1', 'test2', 'test3', 'test4']:
user = User(uid=uid, cn='Some Name', given_name='Some', sn='Name',
terms=['s2021'])
user.add_to_ldap()
@ -31,23 +31,23 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
try:
# missing required position
status, _ = client.post('/api/positions', json={
'vice-president': 'test_1',
'vice-president': 'test1',
})
assert status == 400
# non-existent position
status, _ = client.post('/api/positions', json={
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_3',
'no-such-position': 'test_3',
'president': 'test1',
'vice-president': 'test2',
'sysadmin': 'test3',
'no-such-position': 'test3',
})
assert status == 400
status, data = client.post('/api/positions', json={
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_3',
'president': 'test1',
'vice-president': 'test2',
'sysadmin': 'test3',
})
assert status == 200
expected = [
@ -55,16 +55,16 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
{"status": "in progress", "operation": "update_exec_group_ldap"},
{"status": "in progress", "operation": "subscribe_to_mailing_lists"},
{"status": "completed", "result": {
"president": "test_1",
"vice-president": "test_2",
"sysadmin": "test_3",
"president": ["test1"],
"vice-president": ["test2"],
"sysadmin": ["test3"],
}},
]
assert data == expected
# make sure execs were added to exec group
status, data = client.get('/api/groups/exec')
assert status == 200
expected = ['test_1', 'test_2', 'test_3']
expected = ['test1', 'test2', 'test3']
assert sorted([item['uid'] for item in data['members']]) == expected
# make sure execs were subscribed to mailing lists
addresses = [f'{uid}@{base_domain}' for uid in expected]
@ -72,20 +72,33 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses
_, data = client.post('/api/positions', json={
'president': 'test_1',
'vice-president': 'test_2',
'sysadmin': 'test_2',
'treasurer': 'test_4',
'president': 'test1',
'vice-president': 'test2',
'sysadmin': 'test2',
'treasurer': 'test4',
})
assert data[-1]['status'] == 'completed'
# make sure old exec was removed from group
expected = ['test_1', 'test_2', 'test_4']
expected = ['test1', 'test2', 'test4']
_, data = client.get('/api/groups/exec')
assert sorted([item['uid'] for item in data['members']]) == expected
# make sure old exec was removed from mailing lists
addresses = [f'{uid}@{base_domain}' for uid in expected]
for mailing_list in mailing_lists:
assert sorted(mock_mailman_server.subscriptions[mailing_list]) == addresses
# multiple users per position
status, data = client.post('/api/positions', json={
'president': 'test1',
'vice-president': ['test2', 'test3'],
'sysadmin': 'test2, test3,test4',
})
assert status == 200
assert data[-1] == {'status': 'completed', 'result': {
'president': ['test1'],
'vice-president': ['test2', 'test3'],
'sysadmin': ['test2', 'test3', 'test4'],
}}
finally:
with g_admin_ctx():
for user in users:

View File

@ -60,9 +60,7 @@ exec = exec
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck,
ext-affairs-lead,marketing-lead,design-lead,events-lead,
reps-lead,mods-lead,photography-lead,other
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
username = mysql
@ -93,6 +91,7 @@ members_domain = csclub.cloud
k8s_members_domain = k8s.csclub.cloud
ip_range_min = 172.19.134.10
ip_range_max = 172.19.134.160
reload_web_server_cmd = systemctl reload nginx
[k8s]
members_clusterrole = csc-members-default

View File

@ -59,9 +59,7 @@ exec = exec
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck,
ext-affairs-lead,marketing-lead,design-lead,events-lead,
reps-lead,mods-lead,photography-lead,other
sysadmin,cro,librarian,imapd,webmaster,offsck
[mysql]
username = mysql
@ -92,6 +90,7 @@ members_domain = csclub.cloud
k8s_members_domain = k8s.csclub.cloud
ip_range_min = 172.19.134.10
ip_range_max = 172.19.134.160
reload_web_server_cmd = systemctl reload nginx
[k8s]
members_clusterrole = csc-members-default

View File

@ -298,6 +298,17 @@ def uwldap_srv(cfg, ldap_conn):
delete_subtree(conn, base_dn)
conn.add(base_dn, 'organizationalUnit')
conn.add(
f'uid=ctdalek,{base_dn}',
['inetLocalMailRecipient', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'mailLocalAddress': 'ctdalek@uwaterloo.internal',
'ou': 'Math',
'cn': 'Calum T. Dalek',
'sn': 'Dalek',
'givenName': 'Calum',
},
)
_uwldap_srv = UWLDAPService()
component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv