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 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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
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."""
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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_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:
|
||||||
|
|
|
@ -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 .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 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
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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': [
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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