parent
3b78b7ffb4
commit
e966e3f307
@ -0,0 +1,6 @@ |
||||
class UserNotFoundError(Exception): |
||||
pass |
||||
|
||||
|
||||
class GroupNotFoundError(Exception): |
||||
pass |
@ -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.""" |
@ -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 |
@ -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') |
@ -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