From dd59bea9184530896a331b76233711cfdd6fbd14 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 18 Aug 2021 01:59:24 +0000 Subject: [PATCH] add Kerberos delegation --- .gitignore | 4 + README.md | 34 ++- ceo_common/errors.py | 4 + ceo_common/interfaces/IKerberosService.py | 3 - ceo_common/krb5/__init__.py | 0 ceo_common/krb5/krb5_build.py | 96 +++++++++ ceo_common/krb5/utils.py | 201 ++++++++++++++++++ ceo_common/model/Config.py | 2 + ceo_common/model/HTTPClient.py | 32 +-- ceod/api/app_factory.py | 3 + ceod/api/krb5_cred_handlers.py | 29 +++ ceod/api/members.py | 20 +- ceod/api/utils.py | 16 +- ceod/api/uwldap.py | 13 +- ceod/model/KerberosService.py | 45 +++- ceod/model/LDAPService.py | 16 +- ceod/model/MailmanService.py | 2 +- .../members/AddMemberTransaction.py | 4 +- .../members/DeleteMemberTransaction.py | 47 ++++ .../members/ModifyMemberTransaction.py | 4 +- .../members/ResetPasswordTransaction.py | 27 --- ceod/transactions/members/__init__.py | 2 +- .../uwldap/UpdateProgramsTransaction.py | 20 -- ceod/transactions/uwldap/__init__.py | 1 - ceod/{transactions/members => }/utils.py | 0 gen_cred.py | 14 ++ tests/MockMailmanServer.py | 26 ++- tests/ceod/api/test_members.py | 200 ++++++++++++++++- tests/ceod/model/test_group.py | 4 +- tests/ceod/model/test_mail.py | 1 + tests/ceod/model/test_user.py | 10 +- tests/ceod/model/test_uwldap.py | 7 +- tests/ceod_dev.ini | 9 + tests/ceod_test_local.ini | 1 + tests/conftest.py | 70 ++++-- tests/conftest_ceod_api.py | 60 ++++-- 36 files changed, 853 insertions(+), 174 deletions(-) create mode 100644 ceo_common/krb5/__init__.py create mode 100644 ceo_common/krb5/krb5_build.py create mode 100644 ceo_common/krb5/utils.py create mode 100644 ceod/api/krb5_cred_handlers.py create mode 100644 ceod/transactions/members/DeleteMemberTransaction.py delete mode 100644 ceod/transactions/members/ResetPasswordTransaction.py delete mode 100644 ceod/transactions/uwldap/UpdateProgramsTransaction.py delete mode 100644 ceod/transactions/uwldap/__init__.py rename ceod/{transactions/members => }/utils.py (100%) create mode 100755 gen_cred.py diff --git a/.gitignore b/.gitignore index ea3ce8f..603f13f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ __pycache__/ /venv/ .vscode/ +/cred +*.o +*.so +/ceo_common/krb5/_krb5.c diff --git a/README.md b/README.md index f6533b1..219eb2a 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,25 @@ this repo in one of the dev environment containers. Next, install and activate a virtualenv: ```sh +sudo apt install libkrb5-dev python3-dev python3 -m venv venv . venv/bin/activate pip install -r requirements.txt pip install -r dev-requirements.txt ``` -### Running the application +## C bindings +Due to the lack of a decent Python library for Kerberos we ended up +writing our own C bindings using [cffi](https://cffi.readthedocs.io). +Make sure you compile the bindings: +```sh +cd ceo_common/krb5 +python krb5_build.py +``` +This should create a file named '_krb5.cpython-37m-x86_64-linux-gnu.so'. +This will be imported by other modules in ceo. + +## Running the application ceod is essentially a distributed application, with instances on different hosts offering different services. For example, the ceod instance on mail offers a service to subscribe people to mailing lists, and @@ -35,13 +47,14 @@ Sometimes changes you make in the source code don't show up while Flask is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then restart the app. -### Interacting with the application +## Interacting with the application The client part of ceo hasn't been written yet, so we'll use curl to interact with ceod for now. ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication, and TLS for confidentiality and integrity. In development mode, TLS can be disabled. + First, make sure that your version of curl has been compiled with SPNEGO support: ```sh @@ -49,9 +62,22 @@ curl -V ``` Your should see 'SPNEGO' in the 'Features' section. -Here's an example of using curl with SPNEGO: +The API also uses unconstrained Kerberos delegation when interacting with +the LDAP database. This means that the client obtains a forwarded TGT, then +sends that to ceod, which then uses it to interact with LDAP on the client's +behalf. There is a script called `gen_cred.py` which can generate this +ticket for you. + + +Here's an example of making a request to an endpoint which writes to LDAP: ```sh # Get a Kerberos TGT first kinit -curl --negotiate -u : --service-name ceod -X POST http://mail:9987/api/mailman/csc-general/ctdalek +# Obtain a forwarded TGT +./gen_cred.py phosphoric-acid +# Make the request +curl --negotiate -u : --service-name ceod \ + -H "X-KRB5-CRED: $(cat cred)" \ + -d '{"uid":"test_1","cn":"Test One","program":"Math","terms":["s2021"]}' \ + -X POST http://phosphoric-acid:9987/api/members ``` diff --git a/ceo_common/errors.py b/ceo_common/errors.py index 8bba115..632cc5b 100644 --- a/ceo_common/errors.py +++ b/ceo_common/errors.py @@ -12,6 +12,10 @@ class BadRequest(Exception): pass +class KerberosError(Exception): + pass + + class UserAlreadyExistsError(Exception): def __init__(self): super().__init__('user already exists') diff --git a/ceo_common/interfaces/IKerberosService.py b/ceo_common/interfaces/IKerberosService.py index 81abe35..a836933 100644 --- a/ceo_common/interfaces/IKerberosService.py +++ b/ceo_common/interfaces/IKerberosService.py @@ -4,9 +4,6 @@ from zope.interface import Interface class IKerberosService(Interface): """A utility wrapper around kinit/kadmin.""" - def kinit(): - """Acquire and cache a new TGT.""" - def addprinc(principal: str, password: str): """Add a new principal with the specified password.""" diff --git a/ceo_common/krb5/__init__.py b/ceo_common/krb5/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ceo_common/krb5/krb5_build.py b/ceo_common/krb5/krb5_build.py new file mode 100644 index 0000000..4afc474 --- /dev/null +++ b/ceo_common/krb5/krb5_build.py @@ -0,0 +1,96 @@ +from cffi import FFI + +ffibuilder = FFI() + +# Definitions selectively copied from . +# Add more if necessary. +ffibuilder.cdef(r""" + # define KV5M_DATA ... + typedef int32_t krb5_int32; + typedef krb5_int32 krb5_error_code; + typedef krb5_error_code krb5_magic; + struct _krb5_context; + typedef struct _krb5_context* krb5_context; + struct _krb5_auth_context; + typedef struct _krb5_auth_context * krb5_auth_context; + struct _krb5_ccache; + typedef struct _krb5_ccache *krb5_ccache; + typedef struct krb5_principal_data { + ...; + } krb5_principal_data; + typedef krb5_principal_data * krb5_principal; + typedef const krb5_principal_data *krb5_const_principal; + typedef struct _krb5_creds { + krb5_principal client; + krb5_principal server; + ...; + } krb5_creds; + typedef struct _krb5_data { + krb5_magic magic; + unsigned int length; + char *data; + } krb5_data; + typedef struct krb5_replay_data { + ...; + } krb5_replay_data; + + krb5_error_code krb5_init_context(krb5_context * context); + void krb5_free_context(krb5_context context); + krb5_error_code krb5_auth_con_init( + krb5_context context, krb5_auth_context *auth_context); + krb5_error_code krb5_auth_con_setflags( + krb5_context context, krb5_auth_context auth_context, krb5_int32 flags); + krb5_error_code krb5_auth_con_free( + krb5_context context, krb5_auth_context auth_context); + krb5_error_code + krb5_cc_new_unique(krb5_context context, const char *type, const char *hint, + krb5_ccache *id); + krb5_error_code krb5_cc_default(krb5_context context, krb5_ccache *ccache); + krb5_error_code krb5_cc_resolve( + krb5_context context, const char * name, krb5_ccache * cache); + krb5_error_code + krb5_cc_initialize(krb5_context context, krb5_ccache cache, + krb5_principal principal); + krb5_error_code + krb5_cc_get_principal(krb5_context context, krb5_ccache cache, + krb5_principal *principal); + krb5_error_code krb5_cc_store_cred( + krb5_context context, krb5_ccache cache, krb5_creds *creds); + krb5_error_code krb5_cc_close(krb5_context context, krb5_ccache cache); + krb5_error_code krb5_cc_destroy(krb5_context context, krb5_ccache cache); + krb5_error_code + krb5_build_principal(krb5_context context, + krb5_principal * princ, + unsigned int rlen, + const char * realm, ...); + krb5_error_code + krb5_parse_name(krb5_context context, const char *name, + krb5_principal *principal_out); + void krb5_free_principal(krb5_context context, krb5_principal val); + krb5_error_code + krb5_rd_cred(krb5_context context, krb5_auth_context auth_context, + krb5_data *pcreddata, krb5_creds ***pppcreds, + krb5_replay_data *outdata); + krb5_error_code krb5_fwd_tgt_creds( + krb5_context context, krb5_auth_context auth_context, + const char * rhost, krb5_principal client, krb5_principal server, + krb5_ccache cc, int forwardable, krb5_data * outbuf); + void krb5_free_tgt_creds(krb5_context context, krb5_creds ** tgts); + void krb5_free_data_contents(krb5_context context, krb5_data * val); + krb5_error_code krb5_unparse_name( + krb5_context context, krb5_const_principal principal, char **name); + void krb5_free_unparsed_name(krb5_context context, char *val); + const char * krb5_get_error_message(krb5_context ctx, krb5_error_code code); + void krb5_free_error_message(krb5_context ctx, const char * msg); +""") + +ffibuilder.set_source( + "_krb5", + """ + #include + """, + libraries=['krb5'] +) + +if __name__ == '__main__': + ffibuilder.compile(verbose=True) diff --git a/ceo_common/krb5/utils.py b/ceo_common/krb5/utils.py new file mode 100644 index 0000000..82846f5 --- /dev/null +++ b/ceo_common/krb5/utils.py @@ -0,0 +1,201 @@ +import contextlib +import functools +from typing import Union + +from ._krb5 import ffi, lib +from ceo_common.errors import KerberosError + + +def check_rc(k_ctx, rc: int): + """ + Check the return code of a krb5 function. + An exception is raised if rc != 0. + """ + if rc == 0: + return + c_msg = lib.krb5_get_error_message(k_ctx, rc) + msg = ffi.string(c_msg).decode() + lib.krb5_free_error_message(k_ctx, c_msg) + raise KerberosError(msg) + + +@contextlib.contextmanager +def get_krb5_context(): + """Yields a krb5_context.""" + k_ctx = None + try: + p_k_ctx = ffi.new('krb5_context *') + rc = lib.krb5_init_context(p_k_ctx) + k_ctx = p_k_ctx[0] # dereference the pointer + check_rc(k_ctx, rc) + yield k_ctx + finally: + if k_ctx is not None: + lib.krb5_free_context(k_ctx) + + +@contextlib.contextmanager +def get_krb5_auth_context(k_ctx): + """Yields a krb5_auth_context.""" + a_ctx = None + try: + p_a_ctx = ffi.new('krb5_auth_context *') + rc = lib.krb5_auth_con_init(k_ctx, p_a_ctx) + a_ctx = p_a_ctx[0] + check_rc(k_ctx, rc) + # clear the flags which enable the replay cache + rc = lib.krb5_auth_con_setflags(k_ctx, a_ctx, 0) + check_rc(k_ctx, rc) + yield a_ctx + finally: + if a_ctx is not None: + lib.krb5_auth_con_free(k_ctx, a_ctx) + + +@contextlib.contextmanager +def get_krb5_cc_default(k_ctx): + """Yields the default krb5_ccache.""" + cache = None + try: + p_cache = ffi.new('krb5_ccache *') + rc = lib.krb5_cc_default(k_ctx, p_cache) + check_rc(k_ctx, rc) + cache = p_cache[0] + yield cache + finally: + if cache is not None: + lib.krb5_cc_close(k_ctx, cache) + + +@contextlib.contextmanager +def get_krb5_cc_resolve(k_ctx, name: str): + """ + Resolve a credential cache name. + `name` should have the format 'type:residual'. + """ + cache = None + try: + c_name = ffi.new('char[]', name.encode()) + p_cache = ffi.new('krb5_ccache *') + rc = lib.krb5_cc_resolve(k_ctx, c_name, p_cache) + check_rc(k_ctx, rc) + cache = p_cache[0] + yield cache + finally: + if cache is not None: + lib.krb5_cc_close(k_ctx, cache) + + +def get_fwd_tgt(server: str, cache_name: Union[str, None] = None) -> bytes: + """ + Get a forwarded TGT formatted as a KRB-CRED message. + `server` should have the format 'service/host', + e.g. 'ceod/phosphoric-acid.csclub.uwaterloo.ca'. + + If `cache_name` is None, the default cache will be used; otherwise, + the cache with that name will be used. + """ + if cache_name is None: + cache_function = get_krb5_cc_default + else: + cache_function = functools.partial(get_krb5_cc_resolve, name=cache_name) + + with get_krb5_context() as k_ctx, \ + get_krb5_auth_context(k_ctx) as a_ctx, \ + cache_function(k_ctx) as cache: + server_princ = None + client_princ = None + outbuf = None + try: + # create a server principal from the server string + c_server = ffi.new('char[]', server.encode()) + p_server_princ = ffi.new('krb5_principal *') + rc = lib.krb5_parse_name(k_ctx, c_server, p_server_princ) + check_rc(k_ctx, rc) + server_princ = p_server_princ[0] + # get a client principal from the default cache + p_client_princ = ffi.new('krb5_principal *') + rc = lib.krb5_cc_get_principal(k_ctx, cache, p_client_princ) + check_rc(k_ctx, rc) + client_princ = p_client_princ[0] + # get the forwarded TGT + p_outbuf = ffi.new('krb5_data *') + rc = lib.krb5_fwd_tgt_creds( + k_ctx, a_ctx, ffi.NULL, client_princ, server_princ, + cache, 0, p_outbuf) + check_rc(k_ctx, rc) + outbuf = p_outbuf[0] + return ffi.unpack(outbuf.data, outbuf.length) + finally: + if outbuf is not None: + lib.krb5_free_data_contents(k_ctx, p_outbuf) + if client_princ is not None: + lib.krb5_free_principal(k_ctx, client_princ) + if server_princ is not None: + lib.krb5_free_principal(k_ctx, server_princ) + + +@contextlib.contextmanager +def store_fwd_tgt_creds(cred_data_bytes: bytes): + """ + Stores the credentials found in cred_data_bytes + in a new sub-cache in the default cache (which should be of type DIR), + yields the name of the principal in the credential, then finally removes + the sub-cache from the collection. + + The principal name will have the form 'username@REALM', e.g. + 'ctdalek@CSCLUB.UWATERLOO.CA'. + """ + with get_krb5_context() as k_ctx, get_krb5_auth_context(k_ctx) as a_ctx: + creds_out = None + cache = None + c_name = None + + try: + # fill a krb5_data struct + cred_data = ffi.new('krb5_data *') + cred_data_buf = ffi.new('char[]', cred_data_bytes) + cred_data.magic = lib.KV5M_DATA + cred_data.length = len(cred_data_bytes) + cred_data.data = cred_data_buf + + # read the KRB5-CRED message into a krb5_creds array + p_creds_out = ffi.new('krb5_creds ***') + rc = lib.krb5_rd_cred(k_ctx, a_ctx, cred_data, p_creds_out, ffi.NULL) + check_rc(k_ctx, rc) + creds_out = p_creds_out[0] + # there should only be one cred in the array + cred = creds_out[0] + + # read the name of the client principal + client_princ = cred.client + p_name = ffi.new('char **') + rc = lib.krb5_unparse_name(k_ctx, client_princ, p_name) + check_rc(k_ctx, rc) + c_name = p_name[0] + name = ffi.string(c_name).decode() + + # create a new cache inside the collection (default cache) + p_cache = ffi.new('krb5_ccache *') + cctype = ffi.new('char[]', b'DIR') + rc = lib.krb5_cc_new_unique(k_ctx, cctype, ffi.NULL, p_cache) + check_rc(k_ctx, rc) + cache = p_cache[0] + # initialize the new cache + rc = lib.krb5_cc_initialize(k_ctx, cache, client_princ) + check_rc(k_ctx, rc) + + # store the cred into the cache + rc = lib.krb5_cc_store_cred(k_ctx, cache, cred) + check_rc(k_ctx, rc) + + yield name + finally: + if cache is not None: + # We destroy the cache (instead of closing it) since we want + # to remove it from disk. + lib.krb5_cc_destroy(k_ctx, cache) + if c_name is not None: + lib.krb5_free_unparsed_name(k_ctx, c_name) + if creds_out is not None: + lib.krb5_free_tgt_creds(k_ctx, creds_out) diff --git a/ceo_common/model/Config.py b/ceo_common/model/Config.py index 87af65c..f2ff94e 100644 --- a/ceo_common/model/Config.py +++ b/ceo_common/model/Config.py @@ -27,4 +27,6 @@ class Config: return True if val.lower() in ['false', 'no']: return False + if section.startswith('auxiliary '): + return val.split(',') return val diff --git a/ceo_common/model/HTTPClient.py b/ceo_common/model/HTTPClient.py index c5b8ebe..441a1ac 100644 --- a/ceo_common/model/HTTPClient.py +++ b/ceo_common/model/HTTPClient.py @@ -1,5 +1,6 @@ import socket +from flask import g import gssapi from gssapi.raw.exceptions import ExpiredCredentialsError import requests @@ -22,35 +23,18 @@ class HTTPClient: self.ceod_port = cfg.get('ceod_port') self.base_domain = cfg.get('base_domain') - # 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') + self.krb_realm = cfg.get('ldap_sasl_realm') def request(self, host: str, api_path: str, method='GET', **kwargs): + principal = g.sasl_user + if '@' not in principal: + principal = principal + '@' + self.krb_realm + gssapi_name = gssapi.Name(principal) + creds = gssapi.Credentials(name=gssapi_name, usage='initiate') auth = HTTPSPNEGOAuth( opportunistic_auth=True, target_name='ceod', - creds=self.get_creds(), + creds=creds, ) # always use the FQDN, for HTTPS purposes if '.' not in host: diff --git a/ceod/api/app_factory.py b/ceod/api/app_factory.py index 3189ea6..063e704 100644 --- a/ceod/api/app_factory.py +++ b/ceod/api/app_factory.py @@ -7,6 +7,7 @@ from flask_kerberos import init_kerberos from zope import component from .error_handlers import register_error_handlers +from .krb5_cred_handlers import before_request, teardown_request from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \ IMailmanService, IMailService, IUWLDAPService, IHTTPClient from ceo_common.model import Config, HTTPClient, RemoteMailmanService @@ -42,6 +43,8 @@ def create_app(flask_config={}): app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap') register_error_handlers(app) + app.before_request(before_request) + app.teardown_request(teardown_request) @app.route('/ping') def ping(): diff --git a/ceod/api/krb5_cred_handlers.py b/ceod/api/krb5_cred_handlers.py new file mode 100644 index 0000000..0cf26f7 --- /dev/null +++ b/ceod/api/krb5_cred_handlers.py @@ -0,0 +1,29 @@ +from base64 import b64decode +import traceback + +from flask import g, request + +from ceo_common.logger_factory import logger_factory +from ceo_common.krb5.utils import store_fwd_tgt_creds + +logger = logger_factory(__name__) + + +def before_request(): + if 'x-krb5-cred' not in request.headers: + return + cred = b64decode(request.headers['x-krb5-cred']) + ctx = store_fwd_tgt_creds(cred) + name = ctx.__enter__() + g.stored_creds_ctx = ctx + g.sasl_user = name + + +def teardown_request(err): + if 'stored_creds_ctx' not in g: + return + try: + ctx = g.stored_creds_ctx + ctx.__exit__(None, None, None) + except Exception: + logger.error(traceback.format_exc()) diff --git a/ceod/api/members.py b/ceod/api/members.py index 993d3b8..7a9453d 100644 --- a/ceod/api/members.py +++ b/ceod/api/members.py @@ -3,15 +3,16 @@ from zope import component from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ user_is_in_group, requires_authentication_no_realm, \ - create_streaming_response, create_sync_response + create_streaming_response, create_sync_response, development_only from ceo_common.errors import UserNotFoundError from ceo_common.interfaces import ILDAPService from ceod.transactions.members import ( AddMemberTransaction, ModifyMemberTransaction, RenewMemberTransaction, - ResetPasswordTransaction, + DeleteMemberTransaction, ) +import ceod.utils as utils bp = Blueprint('members', __name__) @@ -78,5 +79,16 @@ def renew_user(username: str): @bp.route('//pwreset', methods=['POST']) @authz_restrict_to_syscom def reset_user_password(username: str): - txn = ResetPasswordTransaction(username) - return create_sync_response(txn) + user = component.getUtility(ILDAPService).get_user(username) + password = utils.gen_password() + user.change_password(password) + + return {'password': password} + + +@bp.route('/', methods=['DELETE']) +@authz_restrict_to_syscom +@development_only +def delete_user(username: str): + txn = DeleteMemberTransaction(username) + return create_streaming_response(txn) diff --git a/ceod/api/utils.py b/ceod/api/utils.py index d50b055..53282ea 100644 --- a/ceod/api/utils.py +++ b/ceod/api/utils.py @@ -6,7 +6,7 @@ import pwd import traceback from typing import Callable, List -from flask import current_app +from flask import current_app, stream_with_context from flask.json import jsonify from flask_kerberos import requires_authentication @@ -103,7 +103,8 @@ def create_streaming_response(txn: AbstractTransaction): 'error': str(err), }) + '\n' - return current_app.response_class(generate(), mimetype='text/plain') + return current_app.response_class( + stream_with_context(generate()), mimetype='text/plain') def create_sync_response(txn: AbstractTransaction): @@ -123,3 +124,14 @@ def create_sync_response(txn: AbstractTransaction): return { 'error': str(err), }, 500 + + +def development_only(f: Callable) -> Callable: + @functools.wraps(f) + def wrapper(*args, **kwargs): + if current_app.config.get('ENV') == 'development': + return f(*args, **kwargs) + return { + 'error': 'This endpoint may only be called in development' + }, 403 + return wrapper diff --git a/ceod/api/uwldap.py b/ceod/api/uwldap.py index 93882dc..cd00fba 100644 --- a/ceod/api/uwldap.py +++ b/ceod/api/uwldap.py @@ -2,9 +2,8 @@ from flask import Blueprint, request from flask.json import jsonify from zope import component -from .utils import create_sync_response, authz_restrict_to_syscom +from .utils import authz_restrict_to_syscom from ceo_common.interfaces import IUWLDAPService, ILDAPService -from ceod.transactions.uwldap import UpdateProgramsTransaction bp = Blueprint('uwldap', __name__) @@ -26,9 +25,9 @@ def update_programs(): ldap_srv = component.getUtility(ILDAPService) body = request.get_json(force=True) members = body.get('members') + kwargs = {'members': members} if body.get('dry_run'): - return jsonify( - ldap_srv.update_programs(dry_run=True, members=members) - ) - txn = UpdateProgramsTransaction(members=members) - return create_sync_response(txn) + members['dry_run'] = True + return jsonify( + ldap_srv.update_programs(**kwargs) + ) diff --git a/ceod/model/KerberosService.py b/ceod/model/KerberosService.py index af16274..7f3f470 100644 --- a/ceod/model/KerberosService.py +++ b/ceod/model/KerberosService.py @@ -1,9 +1,14 @@ import os +import shutil import subprocess +from zope import component from zope.interface import implementer -from ceo_common.interfaces import IKerberosService +from ceo_common.interfaces import IKerberosService, IConfig +from ceo_common.krb5._krb5 import ffi, lib +from ceo_common.krb5.utils import check_rc, get_krb5_context, \ + get_krb5_cc_default @implementer(IKerberosService) @@ -11,15 +16,39 @@ class KerberosService: def __init__( self, admin_principal: str, - cache_dir: str = '/run/ceod/krb5_cache', ): - self.admin_principal = admin_principal - os.makedirs(cache_dir, exist_ok=True) - os.environ['KRB5CCNAME'] = 'DIR:' + cache_dir - self.kinit() + cfg = component.getUtility(IConfig) - def kinit(self): - subprocess.run(['kinit', '-k', self.admin_principal], check=True) + self.admin_principal = admin_principal + self.cache_dir = cfg.get('ceod_krb5_cache_dir') + self.realm = cfg.get('ldap_sasl_realm') + self._initialize_cache() + + def _initialize_cache(self, **kwargs): + if os.path.isdir(self.cache_dir): + shutil.rmtree(self.cache_dir) + os.makedirs(self.cache_dir) + os.environ['KRB5CCNAME'] = 'DIR:' + self.cache_dir + + with get_krb5_context() as k_ctx, get_krb5_cc_default(k_ctx) as cache: + princ = None + try: + # build a principal for 'nobody' + realm = self.realm.encode() + c_realm = ffi.new('char[]', realm) + component = ffi.new('char[]', b'nobody') + p_princ = ffi.new('krb5_principal *') + rc = lib.krb5_build_principal( + k_ctx, p_princ, len(realm), c_realm, component, ffi.NULL) + check_rc(k_ctx, rc) + princ = p_princ[0] + + # initialize the default cache with 'nobody' as the default principal + rc = lib.krb5_cc_initialize(k_ctx, cache, princ) + check_rc(k_ctx, rc) + finally: + if princ is not None: + lib.krb5_free_principal(k_ctx, princ) def addprinc(self, principal: str, password: str): subprocess.run([ diff --git a/ceod/model/LDAPService.py b/ceod/model/LDAPService.py index 1b7e06f..a9ce90e 100644 --- a/ceod/model/LDAPService.py +++ b/ceod/model/LDAPService.py @@ -3,6 +3,7 @@ import grp import pwd from typing import Union, List +from flask import g import ldap3 from zope import component from zope.interface import implementer @@ -28,18 +29,13 @@ class LDAPService: self.member_max_id = cfg.get('members_max_id') self.club_min_id = cfg.get('clubs_min_id') self.club_max_id = cfg.get('clubs_max_id') - self.sasl_user = None - def set_sasl_user(self, sasl_user: Union[str, None]): - # TODO: store SASL user in flask.g instead - self.sasl_user = sasl_user - - def _get_ldap_conn(self, gssapi_bind: bool = True) -> ldap3.Connection: + def _get_ldap_conn(self) -> ldap3.Connection: kwargs = {'auto_bind': True, 'raise_exceptions': True} - if gssapi_bind: + if 'sasl_user' in g: kwargs['authentication'] = ldap3.SASL kwargs['sasl_mechanism'] = ldap3.KERBEROS - kwargs['user'] = self.sasl_user + kwargs['user'] = g.sasl_user # TODO: cache the connection for a single request conn = ldap3.Connection(self.ldap_server_url, **kwargs) return conn @@ -77,12 +73,12 @@ class LDAPService: return group.ldap3_entry.entry_writable() def get_user(self, username: str) -> IUser: - conn = self._get_ldap_conn(False) + conn = self._get_ldap_conn() entry = self._get_readable_entry_for_user(conn, username) return User.deserialize_from_ldap(entry) def get_group(self, cn: str) -> IGroup: - conn = self._get_ldap_conn(False) + conn = self._get_ldap_conn() entry = self._get_readable_entry_for_group(conn, cn) return Group.deserialize_from_ldap(entry) diff --git a/ceod/model/MailmanService.py b/ceod/model/MailmanService.py index 9efdac6..4dd4f50 100644 --- a/ceod/model/MailmanService.py +++ b/ceod/model/MailmanService.py @@ -13,7 +13,7 @@ class MailmanService: def __init__(self): cfg = component.getUtility(IConfig) self.base_domain = cfg.get('base_domain') - self.api_base_url = cfg.get('mailman3_api_base_url') + self.api_base_url = cfg.get('mailman3_api_base_url').rstrip('/') api_username = cfg.get('mailman3_api_username') api_password = cfg.get('mailman3_api_password') self.basic_auth = HTTPBasicAuth(api_username, api_password) diff --git a/ceod/transactions/members/AddMemberTransaction.py b/ceod/transactions/members/AddMemberTransaction.py index c522d7e..c3367d3 100644 --- a/ceod/transactions/members/AddMemberTransaction.py +++ b/ceod/transactions/members/AddMemberTransaction.py @@ -4,10 +4,10 @@ from typing import Union, List from zope import component from ..AbstractTransaction import AbstractTransaction -from .utils import gen_password from ceo_common.interfaces import IConfig, IMailService from ceo_common.logger_factory import logger_factory from ceod.model import User, Group +import ceod.utils as utils logger = logger_factory(__name__) @@ -67,7 +67,7 @@ class AddMemberTransaction(AbstractTransaction): group.add_to_ldap() yield 'add_group_to_ldap' - password = gen_password() + password = utils.gen_password() member.add_to_kerberos(password) yield 'add_user_to_kerberos' diff --git a/ceod/transactions/members/DeleteMemberTransaction.py b/ceod/transactions/members/DeleteMemberTransaction.py new file mode 100644 index 0000000..e78aabb --- /dev/null +++ b/ceod/transactions/members/DeleteMemberTransaction.py @@ -0,0 +1,47 @@ +from zope import component + +from ..AbstractTransaction import AbstractTransaction +from ceo_common.errors import UserNotSubscribedError +from ceo_common.interfaces import ILDAPService, IConfig + + +class DeleteMemberTransaction(AbstractTransaction): + """ + Transaction to permanently delete a member and their resources. + This should only be used during testing. + """ + + operations = [ + 'remove_user_from_ldap', + 'remove_group_from_ldap', + 'remove_user_from_kerberos', + 'delete_home_dir', + 'unsubscribe_from_mailing_list', + ] + + def __init__(self, uid: str): + super().__init__() + self.uid = uid + + def child_execute_iter(self): + ldap_srv = component.getUtility(ILDAPService) + cfg = component.getUtility(IConfig) + user = ldap_srv.get_user(self.uid) + group = ldap_srv.get_group(self.uid) + new_member_list = cfg.get('mailman3_new_member_list') + + user.remove_from_ldap() + yield 'remove_user_from_ldap' + group.remove_from_ldap() + yield 'remove_group_from_ldap' + user.remove_from_kerberos() + yield 'remove_user_from_kerberos' + user.delete_home_dir() + yield 'delete_home_dir' + try: + user.unsubscribe_from_mailing_list(new_member_list) + yield 'unsubscribe_from_mailing_list' + except UserNotSubscribedError: + pass + + self.finish('OK') diff --git a/ceod/transactions/members/ModifyMemberTransaction.py b/ceod/transactions/members/ModifyMemberTransaction.py index a4269a5..2e82349 100644 --- a/ceod/transactions/members/ModifyMemberTransaction.py +++ b/ceod/transactions/members/ModifyMemberTransaction.py @@ -38,12 +38,12 @@ class ModifyMemberTransaction(AbstractTransaction): user = self.ldap_srv.get_user(self.username) self.user = user - if self.login_shell: + if self.login_shell is not None: self.old_login_shell = user.login_shell user.replace_login_shell(self.login_shell) yield 'replace_login_shell' - if self.forwarding_addresses: + if self.forwarding_addresses is not None: self.old_forwarding_addresses = user.get_forwarding_addresses() user.set_forwarding_addresses(self.forwarding_addresses) yield 'replace_forwarding_addresses' diff --git a/ceod/transactions/members/ResetPasswordTransaction.py b/ceod/transactions/members/ResetPasswordTransaction.py deleted file mode 100644 index 5986663..0000000 --- a/ceod/transactions/members/ResetPasswordTransaction.py +++ /dev/null @@ -1,27 +0,0 @@ -from zope import component - -from ..AbstractTransaction import AbstractTransaction -from .utils import gen_password -from ceo_common.interfaces import ILDAPService - - -class ResetPasswordTransaction(AbstractTransaction): - """Transaction to reset a user's password.""" - - operations = [ - 'change_password', - ] - - def __init__(self, username: str): - super().__init__() - self.username = username - self.ldap_srv = component.getUtility(ILDAPService) - - def child_execute_iter(self): - user = self.ldap_srv.get_user(self.username) - - password = gen_password() - user.change_password(password) - yield 'change_password' - - self.finish({'password': password}) diff --git a/ceod/transactions/members/__init__.py b/ceod/transactions/members/__init__.py index e02005a..8234f15 100644 --- a/ceod/transactions/members/__init__.py +++ b/ceod/transactions/members/__init__.py @@ -1,4 +1,4 @@ from .AddMemberTransaction import AddMemberTransaction from .ModifyMemberTransaction import ModifyMemberTransaction from .RenewMemberTransaction import RenewMemberTransaction -from .ResetPasswordTransaction import ResetPasswordTransaction +from .DeleteMemberTransaction import DeleteMemberTransaction diff --git a/ceod/transactions/uwldap/UpdateProgramsTransaction.py b/ceod/transactions/uwldap/UpdateProgramsTransaction.py deleted file mode 100644 index eae67aa..0000000 --- a/ceod/transactions/uwldap/UpdateProgramsTransaction.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Union, List - -from zope import component - -from ..AbstractTransaction import AbstractTransaction -from ceo_common.interfaces import ILDAPService - - -class UpdateProgramsTransaction(AbstractTransaction): - """Transaction to sync the 'program' attribute in CSC LDAP with UW LDAP.""" - - def __init__(self, members: Union[List[str], None]): - super().__init__() - self.members = members - self.ldap_srv = component.getUtility(ILDAPService) - - def child_execute_iter(self): - users_updated = self.ldap_srv.update_programs(members=self.members) - yield 'update_programs' - self.finish(users_updated) diff --git a/ceod/transactions/uwldap/__init__.py b/ceod/transactions/uwldap/__init__.py deleted file mode 100644 index 44f715c..0000000 --- a/ceod/transactions/uwldap/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .UpdateProgramsTransaction import UpdateProgramsTransaction diff --git a/ceod/transactions/members/utils.py b/ceod/utils.py similarity index 100% rename from ceod/transactions/members/utils.py rename to ceod/utils.py diff --git a/gen_cred.py b/gen_cred.py new file mode 100755 index 0000000..f4de443 --- /dev/null +++ b/gen_cred.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from base64 import b64encode +import sys + +from ceo_common.krb5.utils import get_fwd_tgt + +if len(sys.argv) != 2: + print(f'Usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + +b = get_fwd_tgt('ceod/' + sys.argv[1]) +with open('cred', 'wb') as f: + f.write(b64encode(b)) diff --git a/tests/MockMailmanServer.py b/tests/MockMailmanServer.py index 7315afb..96f3e44 100644 --- a/tests/MockMailmanServer.py +++ b/tests/MockMailmanServer.py @@ -13,7 +13,13 @@ class MockMailmanServer: self.runner = web.AppRunner(self.app) self.loop = asyncio.new_event_loop() - self.subscriptions = [] + # add more as necessary + self.subscriptions = { + 'csc-general': [], + 'exec': [], + 'syscom': [], + 'syscom-alerts': [], + } def _start_loop(self): asyncio.set_event_loop(self.loop) @@ -32,18 +38,28 @@ class MockMailmanServer: async def subscribe(self, request): body = await request.post() subscriber = body['subscriber'] - if subscriber in self.subscriptions: + list_id = body['list_id'] + list_id = list_id[:list_id.index('.')] + if list_id not in self.subscriptions: + return web.json_response({ + 'description': 'No such list', + }, status=400) + subscribers = self.subscriptions[list_id] + if subscriber in subscribers: return web.json_response({ 'description': 'user is already subscribed', }, status=409) - self.subscriptions.append(subscriber) + subscribers.append(subscriber) return web.json_response({'status': 'OK'}) async def unsubscribe(self, request): subscriber = request.match_info['address'] - if subscriber not in self.subscriptions: + list_id = request.match_info['mailing_list'] + list_id = list_id[:list_id.index('@')] + subscribers = self.subscriptions.get(list_id, []) + if subscriber not in subscribers: return web.json_response({ 'description': 'user is not subscribed', }, status=404) - self.subscriptions.remove(subscriber) + subscribers.remove(subscriber) return web.json_response({'status': 'OK'}) diff --git a/tests/ceod/api/test_members.py b/tests/ceod/api/test_members.py index d640c66..863abdb 100644 --- a/tests/ceod/api/test_members.py +++ b/tests/ceod/api/test_members.py @@ -1,23 +1,205 @@ +import pwd +import grp +from unittest.mock import patch + +import ldap3 import pytest +import ceod.utils as utils -def test_members_get_user(client): + +def test_api_user_not_found(client): status, data = client.get('/api/members/no_such_user') assert status == 404 assert data['error'] == 'user not found' @pytest.fixture(scope='session') -def create_user_resp(client): - return client.post('/api/members', json={ - 'uid': 'test_jdoe', - 'cn': 'John Doe', +def mocks_for_create_user(): + with patch.object(utils, 'gen_password') as gen_password_mock, \ + patch.object(pwd, 'getpwuid') as getpwuid_mock, \ + patch.object(grp, 'getgrgid') as getgrgid_mock: + gen_password_mock.return_value = 'krb5' + # Normally, if getpwuid or getgrgid do *not* raise a KeyError, + # then LDAPService will skip that UID. Therefore, by raising a + # KeyError, we are making sure that the UID will *not* be skipped. + getpwuid_mock.side_effect = KeyError() + getgrgid_mock.side_effect = KeyError() + yield + + +@pytest.fixture(scope='session') +def create_user_resp(client, mocks_for_create_user): + status, data = client.post('/api/members', json={ + 'uid': 'test_1', + 'cn': 'Test One', 'program': 'Math', 'terms': ['s2021'], }) + assert status == 200 + assert data[-1]['status'] == 'completed' + yield status, data + status, data = client.delete('/api/members/test_1') + assert status == 200 + assert data[-1]['status'] == 'completed' -# def test_create_user(create_user_resp): -# status, data = create_user_resp -# assert status == 200 -# # TODO: check response contents +@pytest.fixture(scope='session') +def create_user_result(create_user_resp): + # convenience method + _, data = create_user_resp + return data[-1]['result'] + + +def test_api_create_user(cfg, create_user_resp): + _, data = create_user_resp + min_uid = cfg.get('members_min_id') + expected = [ + {"status": "in progress", "operation": "add_user_to_ldap"}, + {"status": "in progress", "operation": "add_group_to_ldap"}, + {"status": "in progress", "operation": "add_user_to_kerberos"}, + {"status": "in progress", "operation": "create_home_dir"}, + {"status": "in progress", "operation": "set_forwarding_addresses"}, + {"status": "in progress", "operation": "send_welcome_message"}, + {"status": "in progress", "operation": "subscribe_to_mailing_list"}, + {"status": "completed", "result": { + "cn": "Test One", + "uid": "test_1", + "uid_number": min_uid, + "gid_number": min_uid, + "login_shell": "/bin/bash", + "home_directory": "/tmp/test_users/test_1", + "is_club": False, + "program": "Math", + "terms": ["s2021"], + "forwarding_addresses": [], + "password": "krb5" + }}, + ] + assert data == expected + + +def test_api_next_uid(cfg, client, create_user_result): + min_uid = cfg.get('members_min_id') + _, data = client.post('/api/members', json={ + 'uid': 'test_2', + 'cn': 'Test Two', + 'program': 'Math', + 'terms': ['s2021'], + }) + assert data[-1]['status'] == 'completed' + result = data[-1]['result'] + try: + assert result['uid_number'] == min_uid + 1 + assert result['gid_number'] == min_uid + 1 + finally: + client.delete('/api/members/test_2') + + +def test_api_get_user(cfg, client, create_user_result): + old_data = create_user_result + uid = old_data['uid'] + del old_data['password'] + + status, data = client.get(f'/api/members/{uid}') + assert status == 200 + assert data == old_data + + +def test_api_patch_user(client, create_user_result): + data = create_user_result + uid = data['uid'] + prev_login_shell = data['login_shell'] + prev_forwarding_addresses = data['forwarding_addresses'] + + # replace login shell + new_shell = '/bin/sh' + status, data = client.patch( + f'/api/members/{uid}', json={'login_shell': new_shell}) + assert status == 200 + expected = [ + {"status": "in progress", "operation": "replace_login_shell"}, + {"status": "completed", "result": "OK"}, + ] + assert data == expected + + # replace forwarding addresses + new_forwarding_addresses = [ + 'test@example1.internal', 'test@example2.internal', + ] + status, data = client.patch( + f'/api/members/{uid}', json={ + 'forwarding_addresses': new_forwarding_addresses + } + ) + assert status == 200 + expected = [ + {"status": "in progress", "operation": "replace_forwarding_addresses"}, + {"status": "completed", "result": "OK"}, + ] + assert data == expected + + # retrieve the user and make sure that both fields were changed + status, data = client.get(f'/api/members/{uid}') + assert status == 200 + assert data['login_shell'] == new_shell + assert data['forwarding_addresses'] == new_forwarding_addresses + + # replace (restore) both + status, data = client.patch( + f'/api/members/{uid}', json={ + 'login_shell': prev_login_shell, + 'forwarding_addresses': prev_forwarding_addresses + } + ) + assert status == 200 + expected = [ + {"status": "in progress", "operation": "replace_login_shell"}, + {"status": "in progress", "operation": "replace_forwarding_addresses"}, + {"status": "completed", "result": "OK"}, + ] + assert data == expected + + +def test_api_renew_user(cfg, client, create_user_result, ldap_conn): + data = create_user_result + uid = data['uid'] + old_terms = data['terms'] + old_non_member_terms = data.get('non_member_terms', []) + + new_terms = ['f2021'] + status, data = client.post( + f'/api/members/{uid}/renew', json={'terms': new_terms}) + assert status == 200 + + new_non_member_terms = ['w2022', 's2022'] + status, data = client.post( + f'/api/members/{uid}/renew', json={'non_member_terms': new_non_member_terms}) + assert status == 200 + + # check that the changes were applied + _, data = client.get(f'/api/members/{uid}') + assert data['terms'] == old_terms + new_terms + assert data['non_member_terms'] == old_non_member_terms + new_non_member_terms + + # cleanup + base_dn = cfg.get('ldap_users_base') + dn = f'uid={uid},{base_dn}' + changes = { + 'term': [(ldap3.MODIFY_REPLACE, old_terms)], + 'nonMemberTerm': [(ldap3.MODIFY_REPLACE, old_non_member_terms)], + } + ldap_conn.modify(dn, changes) + + +def test_api_reset_password(client, create_user_result): + uid = create_user_result['uid'] + with patch.object(utils, 'gen_password') as gen_password_mock: + gen_password_mock.return_value = 'new_password' + status, data = client.post(f'/api/members/{uid}/pwreset') + assert status == 200 + assert data['password'] == 'new_password' + # cleanup + status, data = client.post(f'/api/members/{uid}/pwreset') + assert status == 200 + assert data['password'] == 'krb5' diff --git a/tests/ceod/model/test_group.py b/tests/ceod/model/test_group.py index 2ae2d45..c246646 100644 --- a/tests/ceod/model/test_group.py +++ b/tests/ceod/model/test_group.py @@ -3,7 +3,7 @@ import pytest from ceo_common.errors import GroupNotFoundError -def test_group_add_to_ldap(simple_group, ldap_srv): +def test_group_add_to_ldap(simple_group, ldap_srv, g_admin): group = simple_group group.add_to_ldap() @@ -15,7 +15,7 @@ def test_group_add_to_ldap(simple_group, ldap_srv): ldap_srv.get_group(group.cn) -def test_group_members(ldap_group, ldap_srv): +def test_group_members(ldap_group, ldap_srv, g_admin): group = ldap_group group.add_member('member1') diff --git a/tests/ceod/model/test_mail.py b/tests/ceod/model/test_mail.py index 68aacdf..75ea840 100644 --- a/tests/ceod/model/test_mail.py +++ b/tests/ceod/model/test_mail.py @@ -1,5 +1,6 @@ def test_welcome_message(cfg, mock_mail_server, mail_srv, simple_user): base_domain = cfg.get('base_domain') + mock_mail_server.messages.clear() mail_srv.send_welcome_message_to(simple_user) msg = mock_mail_server.messages[0] assert msg['from'] == f'exec@{base_domain}' diff --git a/tests/ceod/model/test_user.py b/tests/ceod/model/test_user.py index cf98924..2d8e92b 100644 --- a/tests/ceod/model/test_user.py +++ b/tests/ceod/model/test_user.py @@ -7,7 +7,7 @@ from ceo_common.errors import UserNotFoundError, UserAlreadyExistsError from ceod.model import User -def test_user_add_to_ldap(cfg, ldap_srv, simple_user): +def test_user_add_to_ldap(cfg, ldap_srv, simple_user, g_admin): user = simple_user min_id = cfg.get('members_min_id') user.add_to_ldap() @@ -23,7 +23,7 @@ def test_user_add_to_ldap(cfg, ldap_srv, simple_user): ldap_srv.get_user(user.uid) -def test_club_add_to_ldap(cfg, ldap_srv, simple_club): +def test_club_add_to_ldap(cfg, ldap_srv, simple_club, g_admin): club = simple_club min_id = cfg.get('clubs_min_id') club.add_to_ldap() @@ -74,7 +74,7 @@ def test_user_forwarding_addresses(cfg, ldap_user): assert not os.path.isdir(user.home_directory) -def test_user_terms(ldap_user, ldap_srv): +def test_user_terms(ldap_user, ldap_srv, g_admin): user = ldap_user user.add_terms(['f2021']) @@ -86,7 +86,7 @@ def test_user_terms(ldap_user, ldap_srv): assert ldap_srv.get_user(user.uid).non_member_terms == user.non_member_terms -def test_user_positions(ldap_user, ldap_srv): +def test_user_positions(ldap_user, ldap_srv, g_admin): user = ldap_user user.add_position('treasurer') @@ -108,7 +108,7 @@ def test_user_change_password(krb_user): user.change_password('new_password') -def test_login_shell(ldap_user, ldap_srv): +def test_login_shell(ldap_user, ldap_srv, g_admin): user = ldap_user user.replace_login_shell('/bin/sh') diff --git a/tests/ceod/model/test_uwldap.py b/tests/ceod/model/test_uwldap.py index 1e623a7..ccb58b5 100644 --- a/tests/ceod/model/test_uwldap.py +++ b/tests/ceod/model/test_uwldap.py @@ -1,7 +1,5 @@ import ldap3 -from tests.conftest import get_ldap_conn - def test_uwldap_get(uwldap_srv, uwldap_user): retrieved_user = uwldap_srv.get_user(uwldap_user.uid) @@ -11,11 +9,12 @@ def test_uwldap_get(uwldap_srv, uwldap_user): assert uwldap_srv.get_user('no_such_user') is None -def test_ldap_updateprograms(cfg, ldap_srv, uwldap_srv, ldap_user, uwldap_user): +def test_ldap_updateprograms( + cfg, ldap_conn, ldap_srv, uwldap_srv, ldap_user, uwldap_user, g_admin): # sanity check assert ldap_user.uid == uwldap_user.uid # modify the user's program in UWLDAP - conn = get_ldap_conn(cfg.get('uwldap_server_url')) + conn = ldap_conn base_dn = cfg.get('uwldap_base') dn = f'uid={uwldap_user.uid},{base_dn}' changes = {'ou': [(ldap3.MODIFY_REPLACE, ['New Program'])]} diff --git a/tests/ceod_dev.ini b/tests/ceod_dev.ini index 13a3aad..974e0b8 100644 --- a/tests/ceod_dev.ini +++ b/tests/ceod_dev.ini @@ -7,6 +7,7 @@ admin_host = phosphoric-acid # this is the host with NFS no_root_squash fs_root_host = phosphoric-acid mailman_host = mail +krb5_cache_dir = /run/ceod/krb5_cache use_https = false port = 9987 @@ -43,3 +44,11 @@ api_base_url = http://localhost:8001/3.1 api_username = restadmin api_password = mailman3 new_member_list = csc-general + +[auxiliary groups] +syscom = office,staff,adm,src +office = cdrom,audio,video,www + +[auxiliary mailing lists] +syscom = syscom,syscom-alerts,syscom-moderators,mirror +exec = exec,exec-moderators diff --git a/tests/ceod_test_local.ini b/tests/ceod_test_local.ini index b82ba2d..bf233bd 100644 --- a/tests/ceod_test_local.ini +++ b/tests/ceod_test_local.ini @@ -5,6 +5,7 @@ base_domain = csclub.internal admin_host = phosphoric-acid fs_root_host = phosphoric-acid mailman_host = mail +krb5_cache_dir = /tmp/ceod_test_krb5_cache use_https = false port = 9987 diff --git a/tests/conftest.py b/tests/conftest.py index 6a47ef9..a75d93e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ import importlib.resources import os import shutil +import subprocess +import tempfile +import flask import ldap3 import pytest import socket @@ -30,16 +33,16 @@ def cfg(): @pytest.fixture(scope='session') def krb_srv(cfg): - # we need to be root to read the keytab + # TODO: create temporary Kerberos database using kdb5_util. + # We need to be root to read the keytab assert os.geteuid() == 0 # this dance again... ugh if socket.gethostname() == cfg.get('ceod_admin_host'): principal = 'ceod/admin' else: principal = 'ceod/' + socket.getfqdn() - cache_dir = '/tmp/ceod_test/krb5_cache' - shutil.rmtree(cache_dir, ignore_errors=True) - krb = KerberosService(principal, cache_dir) + cache_dir = cfg.get('ceod_krb5_cache_dir') + krb = KerberosService(principal) component.provideUtility(krb, IKerberosService) yield krb shutil.rmtree(cache_dir) @@ -55,15 +58,35 @@ def delete_subtree(conn: ldap3.Connection, base_dn: str): pass -def get_ldap_conn(server_url: str): - return ldap3.Connection( - server_url, auto_bind=True, raise_exceptions=True, - authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS) +@pytest.fixture(scope='session') +def ceod_admin_creds(cfg, krb_srv): + """ + Acquire credentials for ceod/admin and store them + in the default ccache. + """ + subprocess.run( + ['kinit', '-k', cfg.get('ldap_admin_principal')], + check=True, + ) @pytest.fixture(scope='session') -def ldap_srv(cfg, krb_srv): - conn = get_ldap_conn(cfg.get('ldap_server_url')) +def ldap_conn(cfg, ceod_admin_creds) -> ldap3.Connection: + # Assume that the same server URL is being used for the CSC + # and UWLDAP during the tests. + cfg = component.getUtility(IConfig) + server_url = cfg.get('ldap_server_url') + # sanity check + assert server_url == cfg.get('uwldap_server_url') + return ldap3.Connection( + server_url, auto_bind=True, raise_exceptions=True, + authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS, + user=cfg.get('ldap_admin_principal')) + + +@pytest.fixture(scope='session') +def ldap_srv(cfg, krb_srv, ldap_conn): + conn = ldap_conn users_base = cfg.get('ldap_users_base') groups_base = cfg.get('ldap_groups_base') @@ -119,8 +142,8 @@ def mailman_srv(mock_mailman_server, cfg, http_client): @pytest.fixture(scope='session') -def uwldap_srv(cfg, ldap_srv): - conn = get_ldap_conn(cfg.get('uwldap_server_url')) +def uwldap_srv(cfg, ldap_conn): + conn = ldap_conn base_dn = cfg.get('uwldap_base') ou = base_dn.split(',', 1)[0].split('=')[1] @@ -167,6 +190,21 @@ def app( return app +@pytest.fixture +def g_admin(cfg, ceod_admin_creds, app): + """ + Store the creds for ceod/admin in flask.g. + This fixture should be used any time LDAP is modified via the LDAPService. + """ + admin_principal = cfg.get('ldap_admin_principal') + with app.app_context(): + try: + flask.g.sasl_user = admin_principal + yield + finally: + flask.g.pop('sasl_user') + + @pytest.fixture def simple_user(): return User( @@ -187,7 +225,7 @@ def simple_club(): @pytest.fixture -def ldap_user(simple_user): +def ldap_user(simple_user, g_admin): simple_user.add_to_ldap() yield simple_user simple_user.remove_from_ldap() @@ -209,15 +247,15 @@ def simple_group(): @pytest.fixture -def ldap_group(simple_group): +def ldap_group(simple_group, g_admin): simple_group.add_to_ldap() yield simple_group simple_group.remove_from_ldap() @pytest.fixture -def uwldap_user(cfg, uwldap_srv): - conn = get_ldap_conn(cfg.get('uwldap_server_url')) +def uwldap_user(cfg, uwldap_srv, ldap_conn): + conn = ldap_conn base_dn = cfg.get('uwldap_base') user = UWLDAPRecord( uid='test_jdoe', diff --git a/tests/conftest_ceod_api.py b/tests/conftest_ceod_api.py index 853da52..70d2fcf 100644 --- a/tests/conftest_ceod_api.py +++ b/tests/conftest_ceod_api.py @@ -1,59 +1,82 @@ +from base64 import b64encode import json import socket +import subprocess +import tempfile from flask.testing import FlaskClient import gssapi import pytest from requests import Request from requests_gssapi import HTTPSPNEGOAuth -from zope import component -from ceo_common.interfaces import IConfig +from ceo_common.krb5.utils import get_fwd_tgt + +__all__ = ['client'] @pytest.fixture(scope='session') def client(app): app_client = app.test_client() - return CeodTestClient(app_client) + with tempfile.TemporaryDirectory() as cache_dir: + yield CeodTestClient(app_client, cache_dir) class CeodTestClient: - def __init__(self, app_client: FlaskClient): - cfg = component.getUtility(IConfig) + def __init__(self, app_client: FlaskClient, cache_dir: str): self.client = app_client - self.admin_principal = cfg.get('ldap_admin_principal') + self.syscom_principal = 'ctdalek' # this is only used for the HTTPSNEGOAuth self.base_url = f'http://{socket.getfqdn()}' - self.cached_auth = {} + # keep a list of all of the principals for which we acquired a TGT + self.principals = [] + # this is where we'll store the credentials for each principal + self.ccache = 'DIR:' + cache_dir def get_auth(self, principal): - if principal in self.cached_auth: - return self.cached_auth[principal] name = gssapi.Name(principal) - creds = gssapi.Credentials(name=name, usage='initiate') + creds = gssapi.Credentials( + name=name, + usage='initiate', + store={'ccache': self.ccache}, + ) auth = HTTPSPNEGOAuth( opportunistic_auth=True, target_name='ceod', creds=creds, ) - self.cached_auth[principal] = auth return auth def get_headers(self, principal): - # method doesn't matter here because we just need the headers + if principal not in self.principals: + # Acquire the initial TGT + subprocess.run( + ['kinit', '-c', self.ccache, principal], + text=True, input='krb5', + check=True, stdout=subprocess.DEVNULL) + self.principals.append(principal) + # Get the Authorization header (SPNEGO). + # The method doesn't matter here because we just need to extract + # the header using req.prepare(). req = Request('GET', self.base_url, auth=self.get_auth(principal)) - return req.prepare().headers.items() + headers = list(req.prepare().headers.items()) + # Get the X-KRB5-CRED header (forwarded TGT). + cred = b64encode( + get_fwd_tgt('ceod/' + socket.getfqdn(), self.ccache) + ).decode() + headers.append(('X-KRB5-CRED', cred)) + return headers def request(self, method, path, principal, **kwargs): if principal is None: - principal = self.admin_principal + principal = self.syscom_principal resp = self.client.open( path, method=method, headers=self.get_headers(principal), **kwargs) status = int(resp.status.split(' ', 1)[0]) - try: + if resp.headers['content-type'] == 'application/json': data = json.loads(resp.data) - except json.JSONDecodeError: - data = resp.data.decode() + else: + data = [json.loads(line) for line in resp.data.splitlines()] return status, data def get(self, path, principal=None, **kwargs): @@ -62,5 +85,8 @@ class CeodTestClient: def post(self, path, principal=None, **kwargs): return self.request('POST', path, principal, **kwargs) + def patch(self, path, principal=None, **kwargs): + return self.request('PATCH', path, principal, **kwargs) + def delete(self, path, principal=None, **kwargs): return self.request('DELETE', path, principal, **kwargs)