add app factory

This commit is contained in:
Max Erenberg 2021-07-24 21:09:10 +00:00
parent 3b78b7ffb4
commit e966e3f307
34 changed files with 748 additions and 99 deletions

6
ceo_common/errors.py Normal file
View File

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

View File

@ -2,6 +2,8 @@ from typing import List
from zope.interface import Interface from zope.interface import Interface
from .IUser import IUser
class IFileService(Interface): class IFileService(Interface):
""" """
@ -9,16 +11,19 @@ class IFileService(Interface):
NFS users' directory. 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. 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, Get the contents of the user's ~/.forward file,
one line at a time. 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.""" """Set the contents of the user's ~/.forward file."""

View File

@ -25,14 +25,14 @@ class IGroup(Interface):
def get_members() -> List[IUser]: def get_members() -> List[IUser]:
"""Get a list of the members in this group.""" """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 Serialize this group into a dict to be passed to
ldap.modlist.addModlist(). ldap.modlist.addModlist().
""" """
# static method # 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(). """Deserialize this group from a dict returned by ldap.search_s().
:returns: IGroup :returns: IGroup

View File

@ -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."""

View File

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

View File

@ -16,6 +16,9 @@ class ILDAPService(Interface):
A new UID and GID will be generated and returned in the new user. 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: def get_group(cn: str, is_club: bool = False) -> IGroup:
"""Retrieve the group with the given cn (Unix group name).""" """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. 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): def modify_user(old_user: IUser, new_user: IUser):
"""Replace old_user with new_user.""" """Replace old_user with new_user."""
@ -33,3 +39,6 @@ class ILDAPService(Interface):
def add_sudo_role(uid: str): def add_sudo_role(uid: str):
"""Create a sudo role for the club with this UID.""" """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."""

View File

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

View File

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

View File

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

View File

@ -31,6 +31,12 @@ class Config:
'mailman3_api_base_url': 'http://localhost:8001/3.1', 'mailman3_api_base_url': 'http://localhost:8001/3.1',
'mailman3_api_username': 'restadmin', 'mailman3_api_username': 'restadmin',
'mailman3_api_password': 'mailman3', '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: def get(self, key: str) -> str:

View File

@ -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)

View File

@ -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()

View File

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

1
ceod/api/__init__.py Normal file
View File

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

73
ceod/api/app_factory.py Normal file
View File

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

22
ceod/api/mailman.py Normal file
View File

@ -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}"}

35
ceod/api/members.py Normal file
View File

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

122
ceod/api/utils.py Normal file
View File

@ -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')

View File

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

View File

@ -40,7 +40,10 @@ class Group:
def add_to_ldap(self): def add_to_ldap(self):
self.ldap_srv.add_group(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 = { data = {
'cn': [self.cn], 'cn': [self.cn],
'gidNumber': [str(self.gid_number)], 'gidNumber': [str(self.gid_number)],
@ -55,7 +58,7 @@ class Group:
return strings_to_bytes(data) return strings_to_bytes(data)
@staticmethod @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) data = bytes_to_strings(data)
return Group( return Group(
cn=data['cn'][0], cn=data['cn'][0],

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ class UWLDAPRecord:
self.mail_local_addresses = mail_local_addresses self.mail_local_addresses = mail_local_addresses
@staticmethod @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 Deserializes a dict returned from ldap.search_s() into a
UWLDAPRecord. UWLDAPRecord.

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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