Merge branch 'master' into feature-80
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Max Erenberg 2023-05-28 18:35:35 -04:00
commit ce3f5978a4
47 changed files with 3053 additions and 653 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

@ -1 +1 @@
1.0.24
1.0.26

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -1,6 +1,52 @@
import datetime
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.

View File

@ -1,9 +1,11 @@
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
from ceo_common.interfaces import ILDAPService
from ceo_common.utils import fuzzy_result, fuzzy_match
from ceod.transactions.groups import (
AddGroupTransaction,
AddMemberToGroupTransaction,
@ -11,6 +13,8 @@ from ceod.transactions.groups import (
DeleteGroupTransaction,
)
from heapq import heappushpop, nlargest
bp = Blueprint('groups', __name__)
@ -32,6 +36,22 @@ 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)
@bp.route('/<group_name>/members/<username>', methods=['POST'])
@authz_restrict_to_syscom
def add_member_to_group(group_name, username):

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'])
@ -119,9 +121,9 @@ def renew_user(username: str):
user.set_expired(False)
try:
user.subscribe_to_mailing_list(member_list)
logger.debug(f'Unsubscribed {user.uid} from {member_list}')
logger.debug(f'Subscribed {user.uid} to {member_list}')
except UserAlreadySubscribedError:
logger.debug(f'{user.uid} is already unsubscribed from {member_list}')
logger.debug(f'{user.uid} is already subscribed to {member_list}')
if terms:
logger.info(f"Renewing member {username} for terms {terms}")

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

View File

@ -62,6 +62,7 @@ class CloudStackService:
domain_id = self._get_domain_id()
url = self._create_url({
'command': 'listAccounts',
'accounttype': '0', # regular user (exclude domain admin)
'domainid': domain_id,
'details': 'min',
})

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,7 +316,9 @@ class LDAPService:
self,
dry_run: bool = False,
members: Union[List[str], None] = None,
uwldap_batch_size: int = 100,
# 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]) + ')'
@ -358,7 +360,11 @@ class LDAPService:
return users_to_change
def _get_club_uids(self, conn: ldap3.Connection) -> List[str]:
conn.search(self.ldap_users_base, '(objectClass=club)', attributes=['uid'])
conn.extend.standard.paged_search(self.ldap_users_base,
'(objectClass=club)',
attributes=['uid'],
paged_size=50,
generator=False)
return [entry.uid.value for entry in conn.entries]
def get_clubs(self) -> List[IGroup]:

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

14
debian/changelog vendored
View File

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

2
debian/compat vendored
View File

@ -1 +1 @@
10
13

28
debian/control vendored
View File

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

5
debian/rules vendored
View File

@ -5,5 +5,10 @@
override_dh_strip:
override_dh_strip_nondeterminism:
override_dh_shlibdeps:
override_dh_makeshlibs:
override_dh_dwz:

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

File diff suppressed because one or more lines are too long

View File

@ -97,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

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

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

@ -77,6 +77,44 @@ 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",
"moon",
"decisive",
"exciting",
"super",
"ambitious",
"acidic",
"addition",
"blue-eyed",
"grate",
"replace",
"natural",
"explode",
"decorous",
"wide",
"hang",
"tomatoes",
"thirsty",
]
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()

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

@ -111,6 +111,206 @@ def test_api_add_member_to_group(client, create_group_result, ldap_user):
assert data['members'] == []
@pytest.fixture(scope='module')
def create_random_names():
# 150 random names generated with https://www.randomlists.com/random-words
random_names = [
"intelligent",
"skin",
"shivering",
"hapless",
"abstracted",
"kiss",
"decision",
"van",
"advise",
"parcel",
"disillusioned",
"print",
"skate",
"robin",
"explode",
"fearless",
"feeling",
"chemical",
"identify",
"baseball",
"room",
"contain",
"smooth",
"play",
"fierce",
"north",
"secretive",
"plug",
"rely",
"home",
"push",
"guard",
"allow",
"depressed",
"evasive",
"slap",
"delicate",
"concern",
"consider",
"fang",
"roll",
"bait",
"rabbits",
"guarded",
"abnormal",
"loutish",
"voracious",
"chase",
"army",
"harsh",
"grieving",
"tacky",
"far",
"wise",
"street",
"price",
"bikes",
"post",
"afternoon",
"deranged",
"cart",
"evanescent",
"shrill",
"uppity",
"adhoc",
"alleged",
"round",
"smart",
"support",
"plantation",
"flap",
"pretty",
"radiate",
"excite",
"memorize",
"whisper",
"thoughtless",
"substantial",
"upset",
"pathetic",
"flow",
"shake",
"wail",
"share",
"songs",
"scream",
"aspiring",
"overwrought",
"mass",
"romantic",
"deliver",
"anxious",
"laborer",
"angry",
"faded",
"wish",
"homeless",
"salty",
"start",
"crooked",
"tremble",
"enjoy",
"chivalrous",
"useless",
"womanly",
"brake",
"wandering",
"please",
"cow",
"reason",
"expert",
"null",
"basket",
"early",
"river",
"prevent",
"sticks",
"vacation",
"eggnog",
"receive",
"memory",
"exchange",
"burly",
"agreement",
"flock",
"subdued",
"clap",
"simplistic",
"tiger",
"responsible",
"knock",
"camera",
"nifty",
"capable",
"disappear",
"afterthought",
"obese",
"harass",
"delicious",
"badge",
"dam",
"plate",
"acrid",
"voiceless",
"mate",
"juice",
"food",
"town",
"giraffe",
"decorate"
]
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:
status, data = client.post('/api/groups', json={
'cn': name,
'description': 'Groups with distinct names for testing searching',
})
assert status == 200
assert data[-1]['status'] == 'completed'
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 = [
(88, 68),
(117, 54),
(63, 97),
(64, 19),
(114, 98),
(45, 146),
(58, 12),
(42, 126),
(66, 137),
(39, 135),
]
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.

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

@ -91,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

@ -90,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