add Kerberos delegation (#5)

This PR adds unconstrained Kerberos delegation to the API.

The client obtains a forwarded TGT and sends it, base64-encoded, in an HTTP header named 'X-KRB5-CRED'. The server reads this credential, creates a new credentials cache for the user, and stores the credential into the new cache. The server can now authenticate to other services (e.g. LDAP) over GSSAPI using the forwarded client's credentials.

Reviewed-on: #5
Co-authored-by: Max Erenberg <merenber@localhost>
Co-committed-by: Max Erenberg <merenber@localhost>
pull/6/head
Max Erenberg 1 year ago
parent d82b5a763b
commit d78d31eec0
  1. 4
      .gitignore
  2. 34
      README.md
  3. 4
      ceo_common/errors.py
  4. 3
      ceo_common/interfaces/IKerberosService.py
  5. 0
      ceo_common/krb5/__init__.py
  6. 96
      ceo_common/krb5/krb5_build.py
  7. 201
      ceo_common/krb5/utils.py
  8. 2
      ceo_common/model/Config.py
  9. 32
      ceo_common/model/HTTPClient.py
  10. 3
      ceod/api/app_factory.py
  11. 29
      ceod/api/krb5_cred_handlers.py
  12. 20
      ceod/api/members.py
  13. 16
      ceod/api/utils.py
  14. 13
      ceod/api/uwldap.py
  15. 43
      ceod/model/KerberosService.py
  16. 16
      ceod/model/LDAPService.py
  17. 2
      ceod/model/MailmanService.py
  18. 4
      ceod/transactions/members/AddMemberTransaction.py
  19. 47
      ceod/transactions/members/DeleteMemberTransaction.py
  20. 4
      ceod/transactions/members/ModifyMemberTransaction.py
  21. 27
      ceod/transactions/members/ResetPasswordTransaction.py
  22. 2
      ceod/transactions/members/__init__.py
  23. 20
      ceod/transactions/uwldap/UpdateProgramsTransaction.py
  24. 1
      ceod/transactions/uwldap/__init__.py
  25. 0
      ceod/utils.py
  26. 14
      gen_cred.py
  27. 26
      tests/MockMailmanServer.py
  28. 200
      tests/ceod/api/test_members.py
  29. 4
      tests/ceod/model/test_group.py
  30. 1
      tests/ceod/model/test_mail.py
  31. 10
      tests/ceod/model/test_user.py
  32. 7
      tests/ceod/model/test_uwldap.py
  33. 9
      tests/ceod_dev.ini
  34. 1
      tests/ceod_test_local.ini
  35. 66
      tests/conftest.py
  36. 60
      tests/conftest_ceod_api.py

4
.gitignore vendored

@ -1,3 +1,7 @@
__pycache__/
/venv/
.vscode/
/cred
*.o
*.so
/ceo_common/krb5/_krb5.c

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

@ -12,6 +12,10 @@ class BadRequest(Exception):
pass
class KerberosError(Exception):
pass
class UserAlreadyExistsError(Exception):
def __init__(self):
super().__init__('user already exists')

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

@ -0,0 +1,96 @@
from cffi import FFI
ffibuilder = FFI()
# Definitions selectively copied from <krb5/krb5.h>.
# 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 <krb5/krb5.h>
""",
libraries=['krb5']
)
if __name__ == '__main__':
ffibuilder.compile(verbose=True)

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

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

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

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

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

@ -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('/<username>/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('/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
@development_only
def delete_user(username: str):
txn = DeleteMemberTransaction(username)
return create_streaming_response(txn)

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

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

@ -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',
):
cfg = component.getUtility(IConfig)
self.admin_principal = admin_principal
os.makedirs(cache_dir, exist_ok=True)
os.environ['KRB5CCNAME'] = 'DIR:' + cache_dir
self.kinit()
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]
def kinit(self):
subprocess.run(['kinit', '-k', self.admin_principal], check=True)
# 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([

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

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

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

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

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

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

@ -1,4 +1,4 @@
from .AddMemberTransaction import AddMemberTransaction
from .ModifyMemberTransaction import ModifyMemberTransaction
from .RenewMemberTransaction import RenewMemberTransaction
from .ResetPasswordTransaction import ResetPasswordTransaction
from .DeleteMemberTransaction import DeleteMemberTransaction

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

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

@ -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]} <ceod hostname>', file=sys.stderr)
sys.exit(1)
b = get_fwd_tgt('ceod/' + sys.argv[1])
with open('cred', 'wb') as f:
f.write(b64encode(b))

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

@ -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'
@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_create_user(create_user_resp):
# status, data = create_user_resp
# assert status == 200
# # TODO: check response contents
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'

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

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

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

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