Add isClubRep attribute (#27)
continuous-integration/drone/push Build is passing Details

Closes #24.

Co-authored-by: Max Erenberg <>
Reviewed-on: #27
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
This commit is contained in:
Max Erenberg 2021-10-23 10:23:43 -04:00
parent 1fcc49ef12
commit e3c50d867a
15 changed files with 128 additions and 10 deletions

View File

@ -20,10 +20,14 @@ attributetype ( 1.3.6.1.4.1.27934.1.1.5 NAME 'nonMemberTerm'
EQUALITY caseIgnoreIA5Match EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{5} ) SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{5} )
attributetype ( 1.3.6.1.4.1.27934.1.1.6 NAME 'isClubRep'
EQUALITY booleanMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 )
objectclass ( 1.3.6.1.4.1.27934.1.2.1 NAME 'member' objectclass ( 1.3.6.1.4.1.27934.1.2.1 NAME 'member'
SUP top AUXILIARY SUP top AUXILIARY
MUST ( cn $ uid ) MUST ( cn $ uid )
MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position ) ) MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position $ isClubRep ) )
objectclass ( 1.3.6.1.4.1.27934.1.2.2 NAME 'club' objectclass ( 1.3.6.1.4.1.27934.1.2.2 NAME 'club'
SUP top AUXILIARY SUP top AUXILIARY

View File

@ -2,9 +2,11 @@
[![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg?ref=refs/heads/v1)](https://ci.csclub.uwaterloo.ca/public/pyceo) [![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg?ref=refs/heads/v1)](https://ci.csclub.uwaterloo.ca/public/pyceo)
CEO (**C**SC **E**lectronic **O**ffice) is the tool used by CSC to manage CEO (**C**SC **E**lectronic **O**ffice) is the tool used by CSC to manage
club accounts and memberships. See [architecture.md](architecture.md) for an club accounts and memberships. See [docs/architecture.md](docs/architecture.md) for an
overview of its architecture. overview of its architecture.
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
## Development ## Development
### Docker ### Docker
If you are not modifying code related to email or Mailman, then you may use If you are not modifying code related to email or Mailman, then you may use

View File

@ -105,6 +105,8 @@ def user_dict_kv(d: Dict) -> List[Tuple[str]]:
pairs.append(('home directory', d['home_directory'])) pairs.append(('home directory', d['home_directory']))
if 'is_club' in d: if 'is_club' in d:
pairs.append(('is a club', str(d['is_club']))) pairs.append(('is a club', str(d['is_club'])))
if 'is_club_rep' in d:
pairs.append(('is a club rep', str(d['is_club_rep'])))
if 'forwarding_addresses' in d: if 'forwarding_addresses' in d:
if len(d['forwarding_addresses']) > 0: if len(d['forwarding_addresses']) > 0:
pairs.append(('forwarding addresses', d['forwarding_addresses'][0])) pairs.append(('forwarding addresses', d['forwarding_addresses'][0]))

View File

@ -19,6 +19,7 @@ class IUser(Interface):
non_member_terms = Attribute('list of terms for which this person was ' non_member_terms = Attribute('list of terms for which this person was '
'a club rep') 'a club rep')
mail_local_addresses = Attribute('email aliases') mail_local_addresses = Attribute('email aliases')
is_club_rep = Attribute('whether this user is a club rep or not')
# Non-LDAP attributes # Non-LDAP attributes
ldap3_entry = Attribute('cached ldap3.Entry instance for this user') ldap3_entry = Attribute('cached ldap3.Entry instance for this user')

View File

@ -20,12 +20,17 @@ bp = Blueprint('members', __name__)
@authz_restrict_to_staff @authz_restrict_to_staff
def create_user(): def create_user():
body = request.get_json(force=True) 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')
txn = AddMemberTransaction( txn = AddMemberTransaction(
uid=body['uid'], uid=body['uid'],
cn=body['cn'], cn=body['cn'],
program=body.get('program'), program=body.get('program'),
terms=body.get('terms'), terms=terms,
non_member_terms=body.get('non_member_terms'), non_member_terms=non_member_terms,
forwarding_addresses=body.get('forwarding_addresses'), forwarding_addresses=body.get('forwarding_addresses'),
) )
return create_streaming_response(txn) return create_streaming_response(txn)
@ -67,6 +72,11 @@ def patch_user(auth_user: str, username: str):
@authz_restrict_to_staff @authz_restrict_to_staff
def renew_user(username: str): def renew_user(username: str):
body = request.get_json(force=True) 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')
ldap_srv = component.getUtility(ILDAPService) ldap_srv = component.getUtility(ILDAPService)
user = ldap_srv.get_user(username) user = ldap_srv.get_user(username)
if body.get('terms'): if body.get('terms'):

View File

@ -197,6 +197,8 @@ class LDAPService:
entry.position = user.positions entry.position = user.positions
if user.mail_local_addresses: if user.mail_local_addresses:
entry.mailLocalAddress = user.mail_local_addresses entry.mailLocalAddress = user.mail_local_addresses
if user.is_club_rep:
entry.isClubRep = True
if not user.is_club(): if not user.is_club():
entry.userPassword = '{SASL}%s@%s' % (user.uid, self.ldap_sasl_realm) entry.userPassword = '{SASL}%s@%s' % (user.uid, self.ldap_sasl_realm)

View File

@ -6,6 +6,7 @@ import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
from .utils import should_be_club_rep
from .validators import is_valid_shell, is_valid_term from .validators import is_valid_shell, is_valid_term
from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService, \ from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService, \
IUser, IConfig, IMailmanService IUser, IConfig, IMailmanService
@ -26,6 +27,7 @@ class User:
home_directory: Union[str, None] = None, home_directory: Union[str, None] = None,
positions: Union[List[str], None] = None, positions: Union[List[str], None] = None,
mail_local_addresses: Union[List[str], None] = None, mail_local_addresses: Union[List[str], None] = None,
is_club_rep: Union[bool, None] = None,
is_club: bool = False, is_club: bool = False,
ldap3_entry: Union[ldap3.Entry, None] = None, ldap3_entry: Union[ldap3.Entry, None] = None,
): ):
@ -52,6 +54,14 @@ class User:
self.positions = positions or [] self.positions = positions or []
self.mail_local_addresses = mail_local_addresses or [] self.mail_local_addresses = mail_local_addresses or []
self._is_club = is_club self._is_club = is_club
if is_club_rep is None:
if is_club:
# not a real user
self.is_club_rep = False
else:
self.is_club_rep = should_be_club_rep(terms, non_member_terms)
else:
self.is_club_rep = is_club_rep
self.ldap3_entry = ldap3_entry self.ldap3_entry = ldap3_entry
self.ldap_srv = component.getUtility(ILDAPService) self.ldap_srv = component.getUtility(ILDAPService)
@ -66,6 +76,7 @@ class User:
'login_shell': self.login_shell, 'login_shell': self.login_shell,
'home_directory': self.home_directory, 'home_directory': self.home_directory,
'is_club': self.is_club(), 'is_club': self.is_club(),
'is_club_rep': self.is_club_rep,
'program': self.program or 'Unknown', 'program': self.program or 'Unknown',
} }
if self.terms: if self.terms:
@ -131,6 +142,7 @@ class User:
home_directory=attrs['homeDirectory'][0], home_directory=attrs['homeDirectory'][0],
positions=attrs.get('position'), positions=attrs.get('position'),
mail_local_addresses=attrs.get('mailLocalAddress'), mail_local_addresses=attrs.get('mailLocalAddress'),
is_club_rep=attrs.get('isClubRep', [False])[0],
is_club=('club' in attrs['objectClass']), is_club=('club' in attrs['objectClass']),
ldap3_entry=entry, ldap3_entry=entry,
) )
@ -148,7 +160,10 @@ class User:
raise Exception('%s is not a valid term' % term) raise Exception('%s is not a valid term' % term)
with self.ldap_srv.entry_ctx_for_user(self) as entry: with self.ldap_srv.entry_ctx_for_user(self) as entry:
entry.term.add(terms) entry.term.add(terms)
if entry.isClubRep.value:
entry.isClubRep.remove()
self.terms.extend(terms) self.terms.extend(terms)
self.is_club_rep = False
def add_non_member_terms(self, terms: List[str]): def add_non_member_terms(self, terms: List[str]):
for term in terms: for term in terms:
@ -156,7 +171,9 @@ class User:
raise Exception('%s is not a valid term' % term) raise Exception('%s is not a valid term' % term)
with self.ldap_srv.entry_ctx_for_user(self) as entry: with self.ldap_srv.entry_ctx_for_user(self) as entry:
entry.nonMemberTerm.add(terms) entry.nonMemberTerm.add(terms)
entry.isClubRep = True
self.non_member_terms.extend(terms) self.non_member_terms.extend(terms)
self.is_club_rep = True
def set_positions(self, positions: List[str]): def set_positions(self, positions: List[str]):
with self.ldap_srv.entry_ctx_for_user(self) as entry: with self.ldap_srv.entry_ctx_for_user(self) as entry:

View File

@ -1,4 +1,6 @@
from typing import Dict, List from typing import Dict, List, Union
from ceo_common.model import Term
def bytes_to_strings(data: Dict[str, List[bytes]]) -> Dict[str, List[str]]: def bytes_to_strings(data: Dict[str, List[bytes]]) -> Dict[str, List[str]]:
@ -25,3 +27,16 @@ def dn_to_uid(dn: str) -> str:
-> 'ctdalek' -> 'ctdalek'
""" """
return dn.split(',', 1)[0].split('=')[1] return dn.split(',', 1)[0].split('=')[1]
def should_be_club_rep(terms: Union[None, List[str]],
non_member_terms: Union[None, List[str]]) -> bool:
"""Returns True iff a user's most recent term was a non-member term."""
if not non_member_terms:
# no non-member terms => was only ever a member
return False
if not terms:
# no member terms => was only ever a club rep
return True
# decide using the most recent term (member or non-member)
return max(map(Term, non_member_terms)) > max(map(Term, terms))

View File

@ -3,7 +3,6 @@ from typing import Union, List
from zope import component from zope import component
from ..AbstractTransaction import AbstractTransaction from ..AbstractTransaction import AbstractTransaction
from ceo_common.errors import BadRequest
from ceo_common.interfaces import ILDAPService from ceo_common.interfaces import ILDAPService
@ -23,8 +22,6 @@ class RenewMemberTransaction(AbstractTransaction):
): ):
super().__init__() super().__init__()
self.username = username self.username = username
if (terms and non_member_terms) or not (terms or non_member_terms):
raise BadRequest('Must specify either terms or non-member terms')
self.terms = terms self.terms = terms
self.non_member_terms = non_member_terms self.non_member_terms = non_member_terms
self.ldap_srv = component.getUtility(ILDAPService) self.ldap_srv = component.getUtility(ILDAPService)

View File

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""
This is a script which adds the isClubRep attribute to all LDAP user records
whose most recent nonMemberTerm is later than their most recent (member) term.
GSSAPI is used for LDAP authentication, so make sure to run `kinit` first.
Also, make sure to run this script from the top-level of the git directory
(see the sys.path hack below).
"""
import os
import sys
import ldap3
sys.path.append(os.getcwd())
from ceod.model.utils import should_be_club_rep
# modify as necessary
LDAP_URI = "ldaps://auth1.csclub.uwaterloo.ca"
LDAP_MEMBERS_BASE = "ou=People,dc=csclub,dc=uwaterloo,dc=ca"
conn = ldap3.Connection(
LDAP_URI, authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS,
auto_bind=True, raise_exceptions=True)
conn.search(LDAP_MEMBERS_BASE, '(objectClass=member)',
attributes=['uid', 'isClubRep', 'term', 'nonMemberTerm'])
total_records_updated = 0
for entry in conn.entries:
if not should_be_club_rep(entry.term.values, entry.nonMemberTerm.values):
continue
if entry.isClubRep.value:
continue
changes = {'isClubRep': [(ldap3.MODIFY_REPLACE, [True])]}
conn.modify(entry.entry_dn, changes)
print('Modified %s' % entry.uid.value)
total_records_updated += 1
print('Total records updated: %d' % total_records_updated)

View File

@ -2,4 +2,4 @@
ignore = ignore =
# line too long # line too long
E501 E501
exclude = .git,.vscode,venv,__pycache__,__init__.py,build,dist exclude = .git,.vscode,venv,__pycache__,__init__.py,build,dist,one_time_scripts

View File

@ -20,6 +20,7 @@ def test_members_get(cli_setup, ldap_user):
f"login shell: {ldap_user.login_shell}\n" f"login shell: {ldap_user.login_shell}\n"
f"home directory: {ldap_user.home_directory}\n" f"home directory: {ldap_user.home_directory}\n"
f"is a club: {ldap_user.is_club()}\n" f"is a club: {ldap_user.is_club()}\n"
f"is a club rep: {ldap_user.is_club_rep}\n"
"forwarding addresses: \n" "forwarding addresses: \n"
f"member terms: {','.join(ldap_user.terms)}\n" f"member terms: {','.join(ldap_user.terms)}\n"
) )
@ -58,7 +59,8 @@ def test_members_add(cli_setup):
"login shell: /bin/bash\n" "login shell: /bin/bash\n"
"home directory: [a-z0-9/_-]+/test_1\n" "home directory: [a-z0-9/_-]+/test_1\n"
"is a club: False\n" "is a club: False\n"
"forwarding addresses: test_1@uwaterloo.internal\n" "is a club rep: False\n"
"forwarding addresses: test_1@uwaterloo\\.internal\n"
"member terms: [sfw]\\d{4}\n" "member terms: [sfw]\\d{4}\n"
"password: \\S+\n$" "password: \\S+\n$"
), re.MULTILINE) ), re.MULTILINE)

View File

@ -56,6 +56,7 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
"login_shell": "/bin/bash", "login_shell": "/bin/bash",
"home_directory": "/tmp/test_users/test_1", "home_directory": "/tmp/test_users/test_1",
"is_club": False, "is_club": False,
"is_club_rep": False,
"program": "Math", "program": "Math",
"terms": ["s2021"], "terms": ["s2021"],
"forwarding_addresses": ['test_1@uwaterloo.internal'], "forwarding_addresses": ['test_1@uwaterloo.internal'],
@ -175,6 +176,7 @@ def test_api_renew_user(cfg, client, create_user_result, ldap_conn):
_, data = client.get(f'/api/members/{uid}') _, data = client.get(f'/api/members/{uid}')
assert data['terms'] == old_terms + new_terms assert data['terms'] == old_terms + new_terms
assert data['non_member_terms'] == old_non_member_terms + new_non_member_terms assert data['non_member_terms'] == old_non_member_terms + new_non_member_terms
assert data['is_club_rep']
# cleanup # cleanup
base_dn = cfg.get('ldap_users_base') base_dn = cfg.get('ldap_users_base')
@ -182,6 +184,7 @@ def test_api_renew_user(cfg, client, create_user_result, ldap_conn):
changes = { changes = {
'term': [(ldap3.MODIFY_REPLACE, old_terms)], 'term': [(ldap3.MODIFY_REPLACE, old_terms)],
'nonMemberTerm': [(ldap3.MODIFY_REPLACE, old_non_member_terms)], 'nonMemberTerm': [(ldap3.MODIFY_REPLACE, old_non_member_terms)],
'isClubRep': [(ldap3.MODIFY_REPLACE, [])],
} }
ldap_conn.modify(dn, changes) ldap_conn.modify(dn, changes)

View File

@ -162,6 +162,7 @@ def test_user_to_dict(cfg):
'login_shell': '/bin/bash', 'login_shell': '/bin/bash',
'home_directory': user.home_directory, 'home_directory': user.home_directory,
'is_club': False, 'is_club': False,
'is_club_rep': False,
} }
assert user.to_dict() == expected assert user.to_dict() == expected
@ -172,3 +173,28 @@ def test_user_to_dict(cfg):
user.create_home_dir() user.create_home_dir()
expected['forwarding_addresses'] = [] expected['forwarding_addresses'] = []
assert user.to_dict(True) == expected assert user.to_dict(True) == expected
def test_user_is_club_rep(ldap_user, ldap_srv):
user = User(
uid='test_jsmith',
cn='John Smith',
program='Math',
terms=['s2021'],
)
assert not user.is_club_rep
club_rep = User(
uid='test_jdoe',
cn='John Doe',
program='Math',
non_member_terms=['s2021'],
)
assert club_rep.is_club_rep
ldap_user.add_terms(['f2021'])
assert not ldap_user.is_club_rep
assert not ldap_srv.get_user(ldap_user.uid).is_club_rep
ldap_user.add_non_member_terms(['w2022'])
assert ldap_user.is_club_rep
assert ldap_srv.get_user(ldap_user.uid).is_club_rep