add app factory

pull/5/head
Max Erenberg 1 year ago
parent 3b78b7ffb4
commit e966e3f307
  1. 6
      ceo_common/errors.py
  2. 11
      ceo_common/interfaces/IFileService.py
  3. 4
      ceo_common/interfaces/IGroup.py
  4. 14
      ceo_common/interfaces/IHTTPClient.py
  5. 3
      ceo_common/interfaces/IKerberosService.py
  6. 9
      ceo_common/interfaces/ILDAPService.py
  7. 15
      ceo_common/interfaces/IUser.py
  8. 1
      ceo_common/interfaces/__init__.py
  9. 16
      ceo_common/logger_factory.py
  10. 6
      ceo_common/model/Config.py
  11. 68
      ceo_common/model/HTTPClient.py
  12. 20
      ceo_common/model/RemoteMailmanService.py
  13. 2
      ceo_common/model/__init__.py
  14. 1
      ceod/api/__init__.py
  15. 73
      ceod/api/app_factory.py
  16. 22
      ceod/api/mailman.py
  17. 35
      ceod/api/members.py
  18. 122
      ceod/api/utils.py
  19. 36
      ceod/model/FileService.py
  20. 7
      ceod/model/Group.py
  21. 17
      ceod/model/KerberosService.py
  22. 54
      ceod/model/LDAPService.py
  23. 2
      ceod/model/SudoRole.py
  24. 2
      ceod/model/UWLDAPRecord.py
  25. 2
      ceod/model/UWLDAPService.py
  26. 71
      ceod/model/User.py
  27. 7
      ceod/model/utils.py
  28. 44
      ceod/transactions/AbstractTransaction.py
  29. 1
      ceod/transactions/__init__.py
  30. 33
      ceod/transactions/mailman/SubscribeMemberTransaction.py
  31. 33
      ceod/transactions/mailman/UnsubscribeMemberTransaction.py
  32. 2
      ceod/transactions/mailman/__init__.py
  33. 107
      ceod/transactions/members/AddMemberTransaction.py
  34. 1
      ceod/transactions/members/__init__.py

@ -0,0 +1,6 @@
class UserNotFoundError(Exception):
pass
class GroupNotFoundError(Exception):
pass

@ -2,6 +2,8 @@ from typing import List
from zope.interface import Interface
from .IUser import IUser
class IFileService(Interface):
"""
@ -9,16 +11,19 @@ class IFileService(Interface):
NFS users' directory.
"""
def create_home_dir(username: str, is_club: bool = False):
def create_home_dir(user: IUser):
"""
Create a new home dir for the given user or club.
"""
def get_forwarding_addresses(username: str) -> List[str]:
def delete_home_dir(user: IUser):
"""Permanently delete a user's home dir."""
def get_forwarding_addresses(user: IUser) -> List[str]:
"""
Get the contents of the user's ~/.forward file,
one line at a time.
"""
def set_forwarding_addresses(username: str, addresses: List[str]):
def set_forwarding_addresses(user: IUser, addresses: List[str]):
"""Set the contents of the user's ~/.forward file."""

@ -25,14 +25,14 @@ class IGroup(Interface):
def get_members() -> List[IUser]:
"""Get a list of the members in this group."""
def serialize_for_modlist() -> Dict[str, List[bytes]]:
def serialize_for_ldap() -> Dict[str, List[bytes]]:
"""
Serialize this group into a dict to be passed to
ldap.modlist.addModlist().
"""
# static method
def deserialize_from_dict(data: Dict[str, List[bytes]]):
def deserialize_from_ldap(data: Dict[str, List[bytes]]):
"""Deserialize this group from a dict returned by ldap.search_s().
:returns: IGroup

@ -0,0 +1,14 @@
from zope.interface import Interface
class IHTTPClient(Interface):
"""A helper class for HTTP requests to ceod."""
def get(host: str, api_path: str, **kwargs):
"""Make a GET request."""
def post(host: str, api_path: str, **kwargs):
"""Make a POST request."""
def delete(host: str, api_path: str, **kwargs):
"""Make a DELETE request."""

@ -10,5 +10,8 @@ class IKerberosService(Interface):
def addprinc(principal: str, password: str):
"""Add a new principal with the specified password."""
def delprinc(principal: str):
"""Remove a principal."""
def change_password(principal: str, password: str):
"""Set and expire the principal's password."""

@ -16,6 +16,9 @@ class ILDAPService(Interface):
A new UID and GID will be generated and returned in the new user.
"""
def remove_user(user: IUser):
"""Remove this user from the database."""
def get_group(cn: str, is_club: bool = False) -> IGroup:
"""Retrieve the group with the given cn (Unix group name)."""
@ -25,6 +28,9 @@ class ILDAPService(Interface):
The GID will not be changed and must be valid.
"""
def remove_group(group: IGroup):
"""Remove this group from the database."""
def modify_user(old_user: IUser, new_user: IUser):
"""Replace old_user with new_user."""
@ -33,3 +39,6 @@ class ILDAPService(Interface):
def add_sudo_role(uid: str):
"""Create a sudo role for the club with this UID."""
def remove_sudo_role(uid: str):
"""Remove the sudo role for this club from the database."""

@ -45,6 +45,9 @@ class IUser(Interface):
def add_to_kerberos(password: str):
"""Add a new Kerberos principal for this user."""
def remove_from_kerberos():
"""Remove this user from Kerberos."""
def add_terms(terms: List[str]):
"""Add member terms for this user."""
@ -58,20 +61,26 @@ class IUser(Interface):
"""Remove a position from this user."""
def change_password(password: str):
"""Replace the user's password."""
"""Replace this user's password."""
def create_home_dir():
"""Create a new home directory for this user."""
def serialize_for_modlist() -> Dict[str, List[bytes]]:
def delete_home_dir():
"""Delete this user's home dir."""
def serialize_for_ldap() -> Dict[str, List[bytes]]:
"""
Serialize this user into a dict to be passed to
ldap.modlist.addModlist().
"""
# static method
def deserialize_from_dict(data: Dict[str, List[bytes]]):
def deserialize_from_ldap(data: Dict[str, List[bytes]]):
"""Deserialize this user from a dict returned by ldap.search_s().
:returns: IUser
"""
def to_dict() -> Dict:
"""Serialize this user into a dict."""

@ -7,3 +7,4 @@ from .IFileService import IFileService
from .IUWLDAPService import IUWLDAPService
from .IMailService import IMailService
from .IMailmanService import IMailmanService
from .IHTTPClient import IHTTPClient

@ -0,0 +1,16 @@
import logging
__ALL__ = ['logger_factory']
def logger_factory(name: str) -> logging.Logger:
logger = logging.getLogger(name)
if logger.hasHandlers():
# already initialized
return logger
logger.setLevel(logging.DEBUG)
log_handler = logging.StreamHandler()
log_handler.setLevel(logging.DEBUG)
log_handler.setFormatter(logging.Formatter('%(levelname)s %(name)s: %(message)s'))
logger.addHandler(log_handler)
return logger

@ -31,6 +31,12 @@ class Config:
'mailman3_api_base_url': 'http://localhost:8001/3.1',
'mailman3_api_username': 'restadmin',
'mailman3_api_password': 'mailman3',
'new_member_list': 'csc-general',
'ceod_admin_host': 'phosphoric-acid',
'fs_root_host': 'phosphoric-acid',
'mailman_host': 'mail',
'ceod_use_https': False,
'ceod_port': 9987,
}
def get(self, key: str) -> str:

@ -0,0 +1,68 @@
import socket
import gssapi
from gssapi.raw.exceptions import ExpiredCredentialsError
import requests
from requests_gssapi import HTTPSPNEGOAuth
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IConfig, IKerberosService, IHTTPClient
@implementer(IHTTPClient)
class HTTPClient:
def __init__(self):
# Determine how to connect to other ceod instances
cfg = component.getUtility(IConfig)
if cfg.get('ceod_use_https'):
self.scheme = 'https'
else:
self.scheme = 'http'
self.ceod_port = cfg.get('ceod_port')
# Determine which principal to use for SPNEGO
# TODO: this code is duplicated in app_factory.py. Figure out
# how to write it only once.
if socket.gethostname() == cfg.get('ceod_admin_host'):
spnego_principal = cfg.get('ldap_admin_principal')
else:
spnego_principal = f'ceod/{socket.getfqdn()}'
# Initialize variables to get Kerberos cache tickets
krb_realm = cfg.get('ldap_sasl_realm')
self.gssapi_name = gssapi.Name(f'{spnego_principal}@{krb_realm}')
self.krb_srv = component.getUtility(IKerberosService)
def get_creds(self):
"""Get GSSAPI credentials to use for SPNEGO."""
for _ in range(2):
try:
creds = gssapi.Credentials(name=self.gssapi_name, usage='initiate')
creds.inquire()
return creds
except ExpiredCredentialsError:
self.krb_srv.kinit()
raise Exception('could not acquire GSSAPI credentials')
def request(self, host: str, api_path: str, method='GET', **kwargs):
auth = HTTPSPNEGOAuth(
opportunistic_auth=True,
target_name='ceod',
creds=self.get_creds(),
)
return requests.request(
method,
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
auth=auth,
**kwargs,
)
def get(self, host: str, api_path: str, **kwargs):
return self.request(host, api_path, 'GET', **kwargs)
def post(self, host: str, api_path: str, **kwargs):
return self.request(host, api_path, 'POST', **kwargs)
def delete(self, host: str, api_path: str, **kwargs):
return self.request(host, api_path, 'DELETE', **kwargs)

@ -0,0 +1,20 @@
from zope import component
from zope.interface import implementer
from ..interfaces import IMailmanService, IConfig, IHTTPClient
@implementer(IMailmanService)
class RemoteMailmanService:
def __init__(self):
cfg = component.getUtility(IConfig)
self.mailman_host = cfg.get('mailman_host')
self.http_client = component.getUtility(IHTTPClient)
def subscribe(self, address: str, mailing_list: str):
resp = self.http_client.post(self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
resp.raise_for_status()
def unsubscribe(self, address: str, mailing_list: str):
resp = self.http_client.delete(self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
resp.raise_for_status()

@ -1 +1,3 @@
from .Config import Config
from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService

@ -0,0 +1 @@
from .app_factory import create_app

@ -0,0 +1,73 @@
import socket
from flask import Flask
from flask_kerberos import init_kerberos
from zope import component
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService
def create_app(flask_config={}):
app = Flask(__name__)
app.config.from_mapping(flask_config)
if app.config.get('TESTING') or app.config.get('ENV') == 'development':
# TODO: create test config class
cfg = Config()
else:
cfg = Config()
component.provideUtility(cfg, IConfig)
init_kerberos(app, service='ceod')
hostname = socket.gethostname()
# Only ceod_admin_host has the ceod/admin key in its keytab
if hostname == cfg.get('ceod_admin_host'):
krb_srv = KerberosService(cfg.get('ldap_admin_principal'))
from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members')
else:
fqdn = socket.getfqdn()
krb_srv = KerberosService(f'ceod/{fqdn}')
component.provideUtility(krb_srv, IKerberosService)
# Any host can use LDAPService, but only ceod_admin_host can write
ldap_srv = LDAPService()
component.provideUtility(ldap_srv, ILDAPService)
http_client = HTTPClient()
component.provideUtility(http_client, IHTTPClient)
# Only instantiate FileService is this host has NFS no_root_squash
if hostname == cfg.get('fs_root_host'):
file_srv = FileService()
component.provideUtility(file_srv, IFileService)
# Only offer mailman API if this host is running Mailman
if hostname == cfg.get('mailman_host'):
mailman_srv = MailmanService()
component.provideUtility(mailman_srv, IMailmanService)
from ceod.api import mailman
app.register_blueprint(mailman.bp, url_prefix='/api/mailman')
else:
mailman_srv = RemoteMailmanService()
component.provideUtility(mailman_srv, IMailmanService)
mail_srv = MailService()
component.provideUtility(mail_srv, IMailService)
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
@app.route('/ping')
def ping():
"""Health check"""
return 'pong\n'
return app

@ -0,0 +1,22 @@
from flask import Blueprint
from .utils import authz_restrict_to_staff
from ceod.transactions.mailman import SubscribeMemberTransaction, UnsubscribeMemberTransaction
bp = Blueprint('mailman', __name__)
@bp.route('/<mailing_list>/<username>', methods=['POST'])
@authz_restrict_to_staff
def subscribe(mailing_list, username):
txn = SubscribeMemberTransaction(username, mailing_list)
txn.execute()
return {'message': f"{username} successfully subscribed to {mailing_list}"}
@bp.route('/<mailing_list>/<username>', methods=['DELETE'])
@authz_restrict_to_staff
def unsubscribe(mailing_list, username):
txn = UnsubscribeMemberTransaction(username, mailing_list)
txn.execute()
return {'message': f"{username} successfully unsubscribed from {mailing_list}"}

@ -0,0 +1,35 @@
from flask import Blueprint, request
from zope import component
from .utils import authz_restrict_to_staff, create_streaming_response
from ceo_common.errors import UserNotFoundError
from ceo_common.interfaces import ILDAPService
from ceod.transactions.members import AddMemberTransaction
bp = Blueprint('members', __name__)
@bp.route('/', methods=['POST'], strict_slashes=False)
@authz_restrict_to_staff
def create_user():
body = request.get_json(force=True)
txn = AddMemberTransaction(
uid=body['uid'],
cn=body['cn'],
program=body.get('program'),
terms=body.get('terms'),
non_member_terms=body.get('non_member_terms'),
forwarding_addresses=body.get('forwarding_addresses'),
)
return create_streaming_response(txn)
@bp.route('/<username>')
def get_user(username: str):
ldap_srv = component.getUtility(ILDAPService)
try:
return ldap_srv.get_user(username).to_dict()
except UserNotFoundError:
return {
'error': 'user not found'
}, 404

@ -0,0 +1,122 @@
import functools
import grp
import json
import socket
from typing import Callable, List
from flask import current_app
from flask_kerberos import requires_authentication
from zope import component
from ceo_common.logger_factory import logger_factory
from ceo_common.interfaces import IConfig
from ceod.transactions import AbstractTransaction
logger = logger_factory(__name__)
def restrict_host(role: str) -> Callable[[Callable], Callable]:
"""
This is a function which returns a decorator.
It returns a 400 if the client makes a request to an endpoint
which is restricted to a different host.
:param role: a key in the app's config (e.g. 'ceod_admin_host')
which maps to a specific hostname
Example:
@app.route('/<mailing_list>/<username>', methods=['POST'])
@restrict_host('mailman_host')
def subscribe(mailing_list, username):
....
"""
hostname = socket.gethostname()
cfg = component.getUtility(IConfig)
desired_hostname = cfg.get(role)
def identity(f: Callable):
return f
def error_decorator(f: Callable):
@functools.wraps(f)
def wrapper(*args, **kwargs):
return {'error': f'Wrong host! Use {desired_hostname} instead'}, 400
return wrapper
if hostname == desired_hostname:
return identity
return error_decorator
def authz_restrict_to_groups(f: Callable, allowed_groups: List[str]) -> Callable:
"""
Restrict an endpoint to users who belong to one or more of the
specified groups.
"""
# TODO: cache group members, but place a time limit on the cache validity
@requires_authentication
@functools.wraps(f)
def wrapper(user: str, *args, **kwargs):
"""
:param user: a Kerberos principal (e.g. 'user1@CSCLUB.UWATERLOO.CA')
"""
logger.debug(f'received request from {user}')
username = user[:user.index('@')]
if username.startswith('ceod/'):
# ceod services are always allowed to make internal calls
return f(*args, **kwargs)
for group in allowed_groups:
for group_member in grp.getgrnam(group).gr_mem:
if username == group_member:
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 wrapper
def authz_restrict_to_staff(f: Callable) -> Callable:
"""A decorator to restrict an endpoint to staff members."""
allowed_groups = ['office', 'staff', 'adm']
return authz_restrict_to_groups(f, allowed_groups)
def authz_restrict_to_syscom(f: Callable) -> Callable:
"""A decorator to restrict an endpoint to syscom members."""
allowed_groups = ['syscom']
return authz_restrict_to_groups(f, allowed_groups)
def create_streaming_response(txn: AbstractTransaction):
"""
Returns a plain text response with one JSON object per line,
indicating the progress of the transaction.
"""
def generate():
try:
for operation in txn.execute_iter():
operation = yield json.dumps({
'status': 'in progress',
'operation': operation,
}) + '\n'
yield json.dumps({
'status': 'completed',
'result': txn.result,
}) + '\n'
except Exception as err:
txn.rollback()
yield json.dumps({
'status': 'aborted',
'error': str(err),
}) + '\n'
return current_app.response_class(generate(), mimetype='text/plain')

@ -1,5 +1,4 @@
import os
import pwd
import shutil
from typing import List
@ -7,7 +6,7 @@ from zope import component
from zope.interface import implementer
from .validators import is_valid_forwarding_address, InvalidForwardingAddressException
from ceo_common.interfaces import IFileService, IConfig
from ceo_common.interfaces import IFileService, IConfig, IUser
@implementer(IFileService)
@ -17,15 +16,17 @@ class FileService:
self.member_home_skel = cfg.get('member_home_skel')
self.club_home_skel = cfg.get('club_home_skel')
def create_home_dir(self, username: str, is_club: bool = False):
if is_club:
def create_home_dir(self, user: IUser):
if user.is_club():
skel_dir = self.club_home_skel
else:
skel_dir = self.member_home_skel
pwnam = pwd.getpwnam(username)
home = pwnam.pw_dir
uid = pwnam.pw_uid
gid = pwnam.pw_gid
home = user.home_directory
# It's important to NOT use pwd here because if the user was recently
# deleted (e.g. as part of a rolled back transaction), their old UID
# and GID numbers will still be in the NSS cache.
uid = user.uid_number
gid = user.gid_number
# recursively copy skel dir to user's home
shutil.copytree(skel_dir, home)
# Set ownership and permissions on user's home.
@ -40,10 +41,11 @@ class FileService:
for file in files:
os.chown(os.path.join(root, file), uid=uid, gid=gid)
def get_forwarding_addresses(self, username: str) -> List[str]:
pwnam = pwd.getpwnam(username)
home = pwnam.pw_dir
forward_file = os.path.join(home, '.forward')
def delete_home_dir(self, user: IUser):
shutil.rmtree(user.home_directory)
def get_forwarding_addresses(self, user: IUser) -> List[str]:
forward_file = os.path.join(user.home_directory, '.forward')
if not os.path.isfile(forward_file):
return []
lines = [
@ -54,15 +56,13 @@ class FileService:
if line != '' and line[0] != '#'
]
def set_forwarding_addresses(self, username: str, addresses: List[str]):
def set_forwarding_addresses(self, user: IUser, addresses: List[str]):
for line in addresses:
if not is_valid_forwarding_address(line):
raise InvalidForwardingAddressException(line)
pwnam = pwd.getpwnam(username)
home = pwnam.pw_dir
uid = pwnam.pw_uid
gid = pwnam.pw_gid
forward_file = os.path.join(home, '.forward')
uid = user.uid_number
gid = user.gid_number
forward_file = os.path.join(user.home_directory, '.forward')
if os.path.exists(forward_file):
# create a backup

@ -40,7 +40,10 @@ class Group:
def add_to_ldap(self):
self.ldap_srv.add_group(self)
def serialize_for_modlist(self) -> Dict[str, List[bytes]]:
def remove_from_ldap(self):
self.ldap_srv.remove_group(self)
def serialize_for_ldap(self) -> Dict[str, List[bytes]]:
data = {
'cn': [self.cn],
'gidNumber': [str(self.gid_number)],
@ -55,7 +58,7 @@ class Group:
return strings_to_bytes(data)
@staticmethod
def deserialize_from_dict(data: Dict[str, List[bytes]]) -> IGroup:
def deserialize_from_ldap(data: Dict[str, List[bytes]]) -> IGroup:
data = bytes_to_strings(data)
return Group(
cn=data['cn'][0],

@ -1,26 +1,22 @@
import os
import subprocess
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IKerberosService
from ceo_common.interfaces import IConfig
@implementer(IKerberosService)
class KerberosService:
def __init__(self):
cfg = component.getUtility(IConfig)
self.admin_principal = cfg.get('ldap_admin_principal')
def __init__(self, admin_principal: str):
self.admin_principal = admin_principal
cache_file = '/run/ceod/krb5_cache'
os.makedirs('/run/ceod', exist_ok=True)
os.putenv('KRB5CCNAME', 'FILE:' + cache_file)
self.kinit()
def kinit(self):
subprocess.run(['kinit', '-k', 'ceod/admin'], check=True)
subprocess.run(['kinit', '-k', self.admin_principal], check=True)
def addprinc(self, principal: str, password: str):
subprocess.run([
@ -31,6 +27,13 @@ class KerberosService:
principal
], check=True)
def delprinc(self, principal: str):
subprocess.run([
'kadmin', '-k', '-p', self.admin_principal, 'delprinc',
'-force',
principal
], check=True)
def change_password(self, principal: str, password: str):
subprocess.run([
'kadmin', '-k', '-p', self.admin_principal, 'cpw',

@ -7,20 +7,13 @@ import ldap.modlist
from zope import component
from zope.interface import implementer
from ceo_common.errors import UserNotFoundError, GroupNotFoundError
from ceo_common.interfaces import ILDAPService, IKerberosService, IConfig, IUser, IGroup
from .User import User
from .Group import Group
from .SudoRole import SudoRole
class UserNotFoundError:
pass
class GroupNotFoundError:
pass
@implementer(ILDAPService)
class LDAPService:
def __init__(self):
@ -59,7 +52,7 @@ class LDAPService:
base = f'uid={username},{self.ldap_users_base}'
try:
_, result = conn.search_s(base, ldap.SCOPE_BASE)[0]
return User.deserialize_from_dict(result)
return User.deserialize_from_ldap(result)
except ldap.NO_SUCH_OBJECT:
raise UserNotFoundError()
@ -68,7 +61,7 @@ class LDAPService:
base = f'cn={cn},{self.ldap_groups_base}'
try:
_, result = conn.search_s(base, ldap.SCOPE_BASE)[0]
return Group.deserialize_from_dict(result)
return Group.deserialize_from_ldap(result)
except ldap.NO_SUCH_OBJECT:
raise GroupNotFoundError()
@ -104,9 +97,14 @@ class LDAPService:
def add_sudo_role(self, uid: str):
conn = self._get_ldap_conn()
sudo_role = SudoRole(uid)
modlist = ldap.modlist.addModlist(sudo_role.serialize_for_modlist())
modlist = ldap.modlist.addModlist(sudo_role.serialize_for_ldap())
conn.add_s(sudo_role.dn, modlist)
def remove_sudo_role(self, uid: str):
conn = self._get_ldap_conn()
sudo_role = SudoRole(uid)
conn.delete_s(sudo_role.dn)
def add_user(self, user: IUser) -> IUser:
if user.is_club():
min_id, max_id = self.club_min_id, self.club_max_id
@ -114,35 +112,43 @@ class LDAPService:
min_id, max_id = self.member_min_id, self.member_max_id
conn = self._get_ldap_conn()
uid_number = self._get_next_uid(conn, min_id, max_id)
new_user = copy.deepcopy(user)
new_user = copy.copy(user)
new_user.uid_number = uid_number
new_user.gid_number = uid_number
modlist = ldap.modlist.addModlist(new_user.serialize_for_modlist())
modlist = ldap.modlist.addModlist(new_user.serialize_for_ldap())
conn.add_s(new_user.dn, modlist)
return new_user
def modify_user(self, old_user: IUser, new_user: IUser):
conn = self._get_ldap_conn()
modlist = ldap.modlist.modifyModlist(
old_user.serialize_for_ldap(),
new_user.serialize_for_ldap(),
)
conn.modify_s(old_user.dn, modlist)
def remove_user(self, user: IUser):
conn = self._get_ldap_conn()
conn.delete_s(user.dn)
def add_group(self, group: IGroup) -> IGroup:
conn = self._get_ldap_conn()
# make sure that the caller initialized the GID number
assert group.gid_number
modlist = ldap.modlist.addModlist(group.serialize_for_modlist())
modlist = ldap.modlist.addModlist(group.serialize_for_ldap())
conn.add_s(group.dn, modlist)
return group
def modify_user(self, old_user: IUser, new_user: IUser):
conn = self._get_ldap_conn()
modlist = ldap.modlist.modifyModlist(
old_user.serialize_for_modlist(),
new_user.serialize_for_modlist(),
)
conn.modify_s(old_user.dn, modlist)
def modify_group(self, old_group: IGroup, new_group: IGroup):
conn = self._get_ldap_conn()
modlist = ldap.modlist.modifyModlist(
old_group.serialize_for_modlist(),
new_group.serialize_for_modlist(),
old_group.serialize_for_ldap(),
new_group.serialize_for_ldap(),
)
conn.modify_s(old_group.dn, modlist)
def remove_group(self, group: IGroup):
conn = self._get_ldap_conn()
conn.delete_s(group.dn)

@ -14,7 +14,7 @@ class SudoRole:
self.uid = uid
self.dn = f'cn=%{uid},{ldap_sudo_base}'
def serialize_for_modlist(self):
def serialize_for_ldap(self):
# TODO: use sudoOrder
data = {
'objectClass': [

@ -17,7 +17,7 @@ class UWLDAPRecord:
self.mail_local_addresses = mail_local_addresses
@staticmethod
def deserialize_from_dict(self, data: Dict[str, List[bytes]]):
def deserialize_from_ldap(self, data: Dict[str, List[bytes]]):
"""
Deserializes a dict returned from ldap.search_s() into a
UWLDAPRecord.

@ -20,4 +20,4 @@ class UWLDAPService:
results = conn.search_s(self.uwldap_base, ldap.SCOPE_SUBTREE, f'uid={username}')
if not results:
return None
return UWLDAPRecord.deserialize_from_dict(results[0])
return UWLDAPRecord.deserialize_from_ldap(results[0])

@ -1,4 +1,5 @@
import copy
import json
import os
from typing import List, Dict, Union
@ -55,32 +56,31 @@ class User:
self.krb_srv = component.getUtility(IKerberosService)
self.file_srv = component.getUtility(IFileService)
def __repr__(self) -> str:
lines = [
'dn: ' + self.dn,
'cn: ' + self.cn,
'uid: ' + self.uid,
'objectClass: top',
'objectClass: account',
'objectClass: posixAccount',
'objectClass: shadowAccount',
'objectClass: ' + ('club' if self.is_club() else 'member'),
'uidNumber: ' + str(self.uid_number),
'gidNumber: ' + str(self.gid_number),
'loginShell: ' + self.login_shell,
'homeDirectory: ' + self.home_directory,
]
def to_dict(self) -> Dict:
data = {
'dn': self.dn,
'cn': self.cn,
'uid': self.uid,
'uid_number': self.uid_number,
'gid_number': self.gid_number,
'login_shell': self.login_shell,
'home_directory': self.home_directory,
'is_club': self.is_club(),
}
if self.program:
lines.append('program: ' + self.program)
for term in self.terms:
lines.append('term: ' + term)
for term in self.non_member_terms:
lines.append('nonMemberTerm: ' + term)
for position in self.positions:
lines.append('position: ' + position)
for address in self.mail_local_addresses:
lines.append('mailLocalAddress: ' + address)
return '\n'.join(lines)
data['program'] = self.program
if self.terms:
data['terms'] = self.terms
if self.non_member_terms:
data['non_member_terms'] = self.non_member_terms
if self.positions:
data['positions'] = self.positions
if self.mail_local_addresses:
data['mail_local_addresses'] = self.mail_local_addresses
return data
def __repr__(self) -> str:
return json.dumps(self.to_dict(), indent=2)
def is_club(self) -> bool:
return self._is_club
@ -90,16 +90,27 @@ class User:
self.uid_number = new_member.uid_number
self.gid_number = new_member.gid_number
def remove_from_ldap(self):
self.ldap_srv.remove_user(self)
self.uid_number = None
self.gid_number = None
def add_to_kerberos(self, password: str):
self.krb_srv.addprinc(self.uid, password)
def remove_from_kerberos(self):
self.krb_srv.delprinc(self.uid)
def change_password(self, password: str):
self.krb_srv.change_password(self.uid, password)
def create_home_dir(self):
self.file_srv.create_home_dir(self.uid, self._is_club)
self.file_srv.create_home_dir(self)
def delete_home_dir(self):
self.file_srv.delete_home_dir(self)
def serialize_for_modlist(self) -> Dict:
def serialize_for_ldap(self) -> Dict:
data = {
'cn': [self.cn],
'loginShell': [self.login_shell],
@ -133,7 +144,7 @@ class User:
return strings_to_bytes(data)
@staticmethod
def deserialize_from_dict(data: Dict[str, List[bytes]]) -> IUser:
def deserialize_from_ldap(data: Dict[str, List[bytes]]) -> IUser:
data = bytes_to_strings(data)
return User(
uid=data['uid'][0],
@ -178,9 +189,9 @@ class User:
self.positions = new_user.positions
def get_forwarding_addresses(self) -> List[str]:
return self.file_srv.get_forwarding_addresses(self.uid)
return self.file_srv.get_forwarding_addresses(self)
def set_forwarding_addresses(self, addresses: List[str]):
self.file_srv.set_forwarding_addresses(self.uid, addresses)
self.file_srv.set_forwarding_addresses(self, addresses)
forwarding_addresses = property(get_forwarding_addresses, set_forwarding_addresses)

@ -1,5 +1,3 @@
import base64
import os
from typing import Dict, List
@ -27,8 +25,3 @@ def dn_to_uid(dn: str) -> str:
-> 'ctdalek'
"""
return dn.split(',', 1)[0].split('=')[1]
def gen_password() -> str:
# good enough
return base64.b64encode(os.urandom(18)).decode()

@ -0,0 +1,44 @@
from abc import ABC, abstractmethod
class AbstractTransaction(ABC):
"""Represents an atomic group of operations."""
# child classes should override this
operations = []
def __init__(self):
self.finished_operations = set()
# child classes should set this to a JSON-serializable object
# once they are finished
self.result = None
def finish(self, result):
self.result = result
@abstractmethod
def child_execute_iter(self):
"""
Template Method design pattern. To be implemented by child classes.
Every time an operation is completed, it should be yielded.
"""
raise NotImplementedError()
def execute_iter(self):
"""
Execute the transaction, yielding an operation each time
one is completed.
"""
for operation in self.child_execute_iter():
self.finished_operations.add(operation)
yield operation
def execute(self):
"""Execute the transaction synchronously."""
for _ in self.execute_iter():
pass
@abstractmethod
def rollback(self):
"""Roll back the transaction, when it fails."""
raise NotImplementedError()

@ -0,0 +1 @@
from .AbstractTransaction import AbstractTransaction

@ -0,0 +1,33 @@
from ..AbstractTransaction import AbstractTransaction
from zope import component
from ceo_common.interfaces import IMailmanService
class SubscribeMemberTransaction(AbstractTransaction):
"""Transaction to subscribe a member to a mailing list."""
operations = [
'subscribe_to_mailing_list',
]
def __init__(self, address: str, mailing_list: str):
"""
:param address: a username or email address
:param mailing_list: the list to which the user will be subscribed
"""
super().__init__()
self.address = address
self.mailing_list = mailing_list
self.mailman_srv = component.getUtility(IMailmanService)
def child_execute_iter(self):
self.mailman_srv.subscribe(self.address, self.mailing_list)
yield 'subscribe_to_mailing_list'
self.finish('success')
def rollback(self):
# nothing to do, since there was only one operation
pass

@ -0,0 +1,33 @@
from ..AbstractTransaction import AbstractTransaction
from zope import component
from ceo_common.interfaces import IMailmanService
class UnsubscribeMemberTransaction(AbstractTransaction):
"""Transaction to unsubscribe a member from a mailing list."""
operations = [
'unsubscribe_from_mailing_list',
]
def __init__(self, address: str, mailing_list: str):
"""
:param address: a username or email address
:param mailing_list: the list from which the user will be unsubscribed
"""
super().__init__()
self.address = address
self.mailing_list = mailing_list
self.mailman_srv = component.getUtility(IMailmanService)
def child_execute_iter(self):
self.mailman_srv.unsubscribe(self.address, self.mailing_list)
yield 'unsubscribe_to_mailing_list'
self.finish('success')
def rollback(self):
# nothing to do, since there was only one operation
pass

@ -0,0 +1,2 @@
from .SubscribeMemberTransaction import SubscribeMemberTransaction
from .UnsubscribeMemberTransaction import UnsubscribeMemberTransaction

@ -0,0 +1,107 @@
import base64
import os
from typing import Union, List
from zope import component
from ..AbstractTransaction import AbstractTransaction
from ceo_common.interfaces import IConfig, IMailService, IMailmanService
from ceod.model import User, Group
def gen_password() -> str:
"""Generate a temporary password."""
return base64.b64encode(os.urandom(18)).decode()
class AddMemberTransaction(AbstractTransaction):
"""Transaction to add a new club member."""
operations = [
'add_user_to_ldap',
'add_group_to_ldap',
'add_user_to_kerberos',
'create_home_dir',
'set_forwarding_addresses',
'subscribe_to_mailing_list',
'send_welcome_message',
]
def __init__(
self,
uid: str,
cn: str,
program: Union[str, None],
terms: Union[List[str], None] = None,
non_member_terms: Union[List[str], None] = None,
forwarding_addresses: Union[List[str], None] = None,
):
super().__init__()
cfg = component.getUtility(IConfig)
self.uid = uid
self.cn = cn
self.program = program
self.terms = terms
self.non_member_terms = non_member_terms
self.forwarding_addresses = forwarding_addresses
self.member = None
self.group = None
self.new_member_list = cfg.get('new_member_list')
self.mail_srv = component.getUtility(IMailService)
self.mailman_srv = component.getUtility(IMailmanService)
def child_execute_iter(self):
member = User(
uid=self.uid,
cn=self.cn,
program=self.program,
terms=self.terms,
non_member_terms=self.non_member_terms,
)
self.member = member
member.add_to_ldap()
yield 'add_user_to_ldap'
group = Group(
cn=member.uid,
gid_number=member.gid_number,
)
self.group = group
group.add_to_ldap()
yield 'add_group_to_ldap'
password = gen_password()
member.add_to_kerberos(password)
yield 'add_user_to_kerberos'
member.create_home_dir()
yield 'create_home_dir'
if self.forwarding_addresses:
member.set_forwarding_addresses(self.forwarding_addresses)
yield 'set_forwarding_addresses'
# The following operations can't/shouldn't be rolled back because the
# user has already seen the email
self.mail_srv.send_welcome_message_to(member)
yield 'send_welcome_message'
# This will be done on mail (remote)
self.mailman_srv.subscribe(member.uid, self.new_member_list)
yield 'subscribe_to_mailing_list'
user_json = member.to_dict()
# insert the password into the JSON so that the client can see it
user_json['password'] = password
self.finish(user_json)
def rollback(self):
if 'create_home_dir' in self.finished_operations:
self.member.delete_home_dir()
if 'add_user_to_kerberos' in self.finished_operations:
self.member.remove_from_kerberos()
if 'add_group_to_ldap' in self.finished_operations:
self.group.remove_from_ldap()
if 'add_user_to_ldap' in self.finished_operations:
self.member.remove_from_ldap()

@ -0,0 +1 @@
from .AddMemberTransaction import AddMemberTransaction
Loading…
Cancel
Save