add app factory
This commit is contained in:
parent
3b78b7ffb4
commit
e966e3f307
|
@ -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 serialize_for_modlist(self) -> Dict:
|
||||
def delete_home_dir(self):
|
||||
self.file_srv.delete_home_dir(self)
|
||||
|
||||
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…
Reference in New Issue