add Kerberos delegation

This commit is contained in:
Max Erenberg 2021-08-18 01:59:24 +00:00
parent d82b5a763b
commit dd59bea918
36 changed files with 853 additions and 174 deletions

4
.gitignore vendored
View File

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

View File

@ -9,13 +9,25 @@ this repo in one of the dev environment containers.
Next, install and activate a virtualenv: Next, install and activate a virtualenv:
```sh ```sh
sudo apt install libkrb5-dev python3-dev
python3 -m venv venv python3 -m venv venv
. venv/bin/activate . venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
pip install -r dev-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 ceod is essentially a distributed application, with instances on different
hosts offering different services. For example, the ceod instance on mail hosts offering different services. For example, the ceod instance on mail
offers a service to subscribe people to mailing lists, and 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 is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
restart the app. 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 The client part of ceo hasn't been written yet, so we'll use curl to
interact with ceod for now. interact with ceod for now.
ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication, ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication,
and TLS for confidentiality and integrity. In development mode, TLS can be and TLS for confidentiality and integrity. In development mode, TLS can be
disabled. disabled.
First, make sure that your version of curl has been compiled with SPNEGO First, make sure that your version of curl has been compiled with SPNEGO
support: support:
```sh ```sh
@ -49,9 +62,22 @@ curl -V
``` ```
Your should see 'SPNEGO' in the 'Features' section. 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 ```sh
# Get a Kerberos TGT first # Get a Kerberos TGT first
kinit 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
``` ```

View File

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

View File

@ -4,9 +4,6 @@ from zope.interface import Interface
class IKerberosService(Interface): class IKerberosService(Interface):
"""A utility wrapper around kinit/kadmin.""" """A utility wrapper around kinit/kadmin."""
def kinit():
"""Acquire and cache a new TGT."""
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."""

View File

View File

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

201
ceo_common/krb5/utils.py Normal file
View File

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

View File

@ -27,4 +27,6 @@ class Config:
return True return True
if val.lower() in ['false', 'no']: if val.lower() in ['false', 'no']:
return False return False
if section.startswith('auxiliary '):
return val.split(',')
return val return val

View File

@ -1,5 +1,6 @@
import socket import socket
from flask import g
import gssapi import gssapi
from gssapi.raw.exceptions import ExpiredCredentialsError from gssapi.raw.exceptions import ExpiredCredentialsError
import requests import requests
@ -22,35 +23,18 @@ class HTTPClient:
self.ceod_port = cfg.get('ceod_port') self.ceod_port = cfg.get('ceod_port')
self.base_domain = cfg.get('base_domain') self.base_domain = cfg.get('base_domain')
# Determine which principal to use for SPNEGO self.krb_realm = cfg.get('ldap_sasl_realm')
# 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): 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( auth = HTTPSPNEGOAuth(
opportunistic_auth=True, opportunistic_auth=True,
target_name='ceod', target_name='ceod',
creds=self.get_creds(), creds=creds,
) )
# always use the FQDN, for HTTPS purposes # always use the FQDN, for HTTPS purposes
if '.' not in host: if '.' not in host:

View File

@ -7,6 +7,7 @@ from flask_kerberos import init_kerberos
from zope import component from zope import component
from .error_handlers import register_error_handlers 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, \ from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient IMailmanService, IMailService, IUWLDAPService, IHTTPClient
from ceo_common.model import Config, HTTPClient, RemoteMailmanService 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') app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
register_error_handlers(app) register_error_handlers(app)
app.before_request(before_request)
app.teardown_request(teardown_request)
@app.route('/ping') @app.route('/ping')
def ping(): def ping():

View File

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

View File

@ -3,15 +3,16 @@ from zope import component
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
user_is_in_group, requires_authentication_no_realm, \ 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.errors import UserNotFoundError
from ceo_common.interfaces import ILDAPService from ceo_common.interfaces import ILDAPService
from ceod.transactions.members import ( from ceod.transactions.members import (
AddMemberTransaction, AddMemberTransaction,
ModifyMemberTransaction, ModifyMemberTransaction,
RenewMemberTransaction, RenewMemberTransaction,
ResetPasswordTransaction, DeleteMemberTransaction,
) )
import ceod.utils as utils
bp = Blueprint('members', __name__) bp = Blueprint('members', __name__)
@ -78,5 +79,16 @@ def renew_user(username: str):
@bp.route('/<username>/pwreset', methods=['POST']) @bp.route('/<username>/pwreset', methods=['POST'])
@authz_restrict_to_syscom @authz_restrict_to_syscom
def reset_user_password(username: str): def reset_user_password(username: str):
txn = ResetPasswordTransaction(username) user = component.getUtility(ILDAPService).get_user(username)
return create_sync_response(txn) 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)

View File

@ -6,7 +6,7 @@ import pwd
import traceback import traceback
from typing import Callable, List 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.json import jsonify
from flask_kerberos import requires_authentication from flask_kerberos import requires_authentication
@ -103,7 +103,8 @@ def create_streaming_response(txn: AbstractTransaction):
'error': str(err), 'error': str(err),
}) + '\n' }) + '\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): def create_sync_response(txn: AbstractTransaction):
@ -123,3 +124,14 @@ def create_sync_response(txn: AbstractTransaction):
return { return {
'error': str(err), 'error': str(err),
}, 500 }, 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

View File

@ -2,9 +2,8 @@ from flask import Blueprint, request
from flask.json import jsonify from flask.json import jsonify
from zope import component 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 ceo_common.interfaces import IUWLDAPService, ILDAPService
from ceod.transactions.uwldap import UpdateProgramsTransaction
bp = Blueprint('uwldap', __name__) bp = Blueprint('uwldap', __name__)
@ -26,9 +25,9 @@ def update_programs():
ldap_srv = component.getUtility(ILDAPService) ldap_srv = component.getUtility(ILDAPService)
body = request.get_json(force=True) body = request.get_json(force=True)
members = body.get('members') members = body.get('members')
kwargs = {'members': members}
if body.get('dry_run'): if body.get('dry_run'):
members['dry_run'] = True
return jsonify( return jsonify(
ldap_srv.update_programs(dry_run=True, members=members) ldap_srv.update_programs(**kwargs)
) )
txn = UpdateProgramsTransaction(members=members)
return create_sync_response(txn)

View File

@ -1,9 +1,14 @@
import os import os
import shutil
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, 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) @implementer(IKerberosService)
@ -11,15 +16,39 @@ class KerberosService:
def __init__( def __init__(
self, self,
admin_principal: str, admin_principal: str,
cache_dir: str = '/run/ceod/krb5_cache',
): ):
self.admin_principal = admin_principal cfg = component.getUtility(IConfig)
os.makedirs(cache_dir, exist_ok=True)
os.environ['KRB5CCNAME'] = 'DIR:' + cache_dir
self.kinit()
def kinit(self): self.admin_principal = admin_principal
subprocess.run(['kinit', '-k', self.admin_principal], check=True) 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): def addprinc(self, principal: str, password: str):
subprocess.run([ subprocess.run([

View File

@ -3,6 +3,7 @@ import grp
import pwd import pwd
from typing import Union, List from typing import Union, List
from flask import g
import ldap3 import ldap3
from zope import component from zope import component
from zope.interface import implementer from zope.interface import implementer
@ -28,18 +29,13 @@ class LDAPService:
self.member_max_id = cfg.get('members_max_id') self.member_max_id = cfg.get('members_max_id')
self.club_min_id = cfg.get('clubs_min_id') self.club_min_id = cfg.get('clubs_min_id')
self.club_max_id = cfg.get('clubs_max_id') self.club_max_id = cfg.get('clubs_max_id')
self.sasl_user = None
def set_sasl_user(self, sasl_user: Union[str, None]): def _get_ldap_conn(self) -> ldap3.Connection:
# TODO: store SASL user in flask.g instead
self.sasl_user = sasl_user
def _get_ldap_conn(self, gssapi_bind: bool = True) -> ldap3.Connection:
kwargs = {'auto_bind': True, 'raise_exceptions': True} kwargs = {'auto_bind': True, 'raise_exceptions': True}
if gssapi_bind: if 'sasl_user' in g:
kwargs['authentication'] = ldap3.SASL kwargs['authentication'] = ldap3.SASL
kwargs['sasl_mechanism'] = ldap3.KERBEROS kwargs['sasl_mechanism'] = ldap3.KERBEROS
kwargs['user'] = self.sasl_user kwargs['user'] = g.sasl_user
# TODO: cache the connection for a single request # TODO: cache the connection for a single request
conn = ldap3.Connection(self.ldap_server_url, **kwargs) conn = ldap3.Connection(self.ldap_server_url, **kwargs)
return conn return conn
@ -77,12 +73,12 @@ class LDAPService:
return group.ldap3_entry.entry_writable() return group.ldap3_entry.entry_writable()
def get_user(self, username: str) -> IUser: 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) entry = self._get_readable_entry_for_user(conn, username)
return User.deserialize_from_ldap(entry) return User.deserialize_from_ldap(entry)
def get_group(self, cn: str) -> IGroup: 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) entry = self._get_readable_entry_for_group(conn, cn)
return Group.deserialize_from_ldap(entry) return Group.deserialize_from_ldap(entry)

View File

@ -13,7 +13,7 @@ class MailmanService:
def __init__(self): def __init__(self):
cfg = component.getUtility(IConfig) cfg = component.getUtility(IConfig)
self.base_domain = cfg.get('base_domain') 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_username = cfg.get('mailman3_api_username')
api_password = cfg.get('mailman3_api_password') api_password = cfg.get('mailman3_api_password')
self.basic_auth = HTTPBasicAuth(api_username, api_password) self.basic_auth = HTTPBasicAuth(api_username, api_password)

View File

@ -4,10 +4,10 @@ from typing import Union, List
from zope import component from zope import component
from ..AbstractTransaction import AbstractTransaction from ..AbstractTransaction import AbstractTransaction
from .utils import gen_password
from ceo_common.interfaces import IConfig, IMailService from ceo_common.interfaces import IConfig, IMailService
from ceo_common.logger_factory import logger_factory from ceo_common.logger_factory import logger_factory
from ceod.model import User, Group from ceod.model import User, Group
import ceod.utils as utils
logger = logger_factory(__name__) logger = logger_factory(__name__)
@ -67,7 +67,7 @@ class AddMemberTransaction(AbstractTransaction):
group.add_to_ldap() group.add_to_ldap()
yield 'add_group_to_ldap' yield 'add_group_to_ldap'
password = gen_password() password = utils.gen_password()
member.add_to_kerberos(password) member.add_to_kerberos(password)
yield 'add_user_to_kerberos' yield 'add_user_to_kerberos'

View File

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

View File

@ -38,12 +38,12 @@ class ModifyMemberTransaction(AbstractTransaction):
user = self.ldap_srv.get_user(self.username) user = self.ldap_srv.get_user(self.username)
self.user = user self.user = user
if self.login_shell: if self.login_shell is not None:
self.old_login_shell = user.login_shell self.old_login_shell = user.login_shell
user.replace_login_shell(self.login_shell) user.replace_login_shell(self.login_shell)
yield 'replace_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() self.old_forwarding_addresses = user.get_forwarding_addresses()
user.set_forwarding_addresses(self.forwarding_addresses) user.set_forwarding_addresses(self.forwarding_addresses)
yield 'replace_forwarding_addresses' yield 'replace_forwarding_addresses'

View File

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

View File

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

View File

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

View File

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

14
gen_cred.py Executable file
View File

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

View File

@ -13,7 +13,13 @@ class MockMailmanServer:
self.runner = web.AppRunner(self.app) self.runner = web.AppRunner(self.app)
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
self.subscriptions = [] # add more as necessary
self.subscriptions = {
'csc-general': [],
'exec': [],
'syscom': [],
'syscom-alerts': [],
}
def _start_loop(self): def _start_loop(self):
asyncio.set_event_loop(self.loop) asyncio.set_event_loop(self.loop)
@ -32,18 +38,28 @@ class MockMailmanServer:
async def subscribe(self, request): async def subscribe(self, request):
body = await request.post() body = await request.post()
subscriber = body['subscriber'] 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({ return web.json_response({
'description': 'user is already subscribed', 'description': 'user is already subscribed',
}, status=409) }, status=409)
self.subscriptions.append(subscriber) subscribers.append(subscriber)
return web.json_response({'status': 'OK'}) return web.json_response({'status': 'OK'})
async def unsubscribe(self, request): async def unsubscribe(self, request):
subscriber = request.match_info['address'] 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({ return web.json_response({
'description': 'user is not subscribed', 'description': 'user is not subscribed',
}, status=404) }, status=404)
self.subscriptions.remove(subscriber) subscribers.remove(subscriber)
return web.json_response({'status': 'OK'}) return web.json_response({'status': 'OK'})

View File

@ -1,23 +1,205 @@
import pwd
import grp
from unittest.mock import patch
import ldap3
import pytest 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') status, data = client.get('/api/members/no_such_user')
assert status == 404 assert status == 404
assert data['error'] == 'user not found' assert data['error'] == 'user not found'
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def create_user_resp(client): def mocks_for_create_user():
return client.post('/api/members', json={ with patch.object(utils, 'gen_password') as gen_password_mock, \
'uid': 'test_jdoe', patch.object(pwd, 'getpwuid') as getpwuid_mock, \
'cn': 'John Doe', 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', 'program': 'Math',
'terms': ['s2021'], '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): @pytest.fixture(scope='session')
# status, data = create_user_resp def create_user_result(create_user_resp):
# assert status == 200 # convenience method
# # TODO: check response contents _, 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'

View File

@ -3,7 +3,7 @@ import pytest
from ceo_common.errors import GroupNotFoundError 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 = simple_group
group.add_to_ldap() group.add_to_ldap()
@ -15,7 +15,7 @@ def test_group_add_to_ldap(simple_group, ldap_srv):
ldap_srv.get_group(group.cn) 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 = ldap_group
group.add_member('member1') group.add_member('member1')

View File

@ -1,5 +1,6 @@
def test_welcome_message(cfg, mock_mail_server, mail_srv, simple_user): def test_welcome_message(cfg, mock_mail_server, mail_srv, simple_user):
base_domain = cfg.get('base_domain') base_domain = cfg.get('base_domain')
mock_mail_server.messages.clear()
mail_srv.send_welcome_message_to(simple_user) mail_srv.send_welcome_message_to(simple_user)
msg = mock_mail_server.messages[0] msg = mock_mail_server.messages[0]
assert msg['from'] == f'exec@{base_domain}' assert msg['from'] == f'exec@{base_domain}'

View File

@ -7,7 +7,7 @@ from ceo_common.errors import UserNotFoundError, UserAlreadyExistsError
from ceod.model import User 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 user = simple_user
min_id = cfg.get('members_min_id') min_id = cfg.get('members_min_id')
user.add_to_ldap() 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) 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 club = simple_club
min_id = cfg.get('clubs_min_id') min_id = cfg.get('clubs_min_id')
club.add_to_ldap() club.add_to_ldap()
@ -74,7 +74,7 @@ def test_user_forwarding_addresses(cfg, ldap_user):
assert not os.path.isdir(user.home_directory) 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 = ldap_user
user.add_terms(['f2021']) 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 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 = ldap_user
user.add_position('treasurer') user.add_position('treasurer')
@ -108,7 +108,7 @@ def test_user_change_password(krb_user):
user.change_password('new_password') 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 = ldap_user
user.replace_login_shell('/bin/sh') user.replace_login_shell('/bin/sh')

View File

@ -1,7 +1,5 @@
import ldap3 import ldap3
from tests.conftest import get_ldap_conn
def test_uwldap_get(uwldap_srv, uwldap_user): def test_uwldap_get(uwldap_srv, uwldap_user):
retrieved_user = uwldap_srv.get_user(uwldap_user.uid) 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 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 # sanity check
assert ldap_user.uid == uwldap_user.uid assert ldap_user.uid == uwldap_user.uid
# modify the user's program in UWLDAP # 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') base_dn = cfg.get('uwldap_base')
dn = f'uid={uwldap_user.uid},{base_dn}' dn = f'uid={uwldap_user.uid},{base_dn}'
changes = {'ou': [(ldap3.MODIFY_REPLACE, ['New Program'])]} changes = {'ou': [(ldap3.MODIFY_REPLACE, ['New Program'])]}

View File

@ -7,6 +7,7 @@ admin_host = phosphoric-acid
# this is the host with NFS no_root_squash # this is the host with NFS no_root_squash
fs_root_host = phosphoric-acid fs_root_host = phosphoric-acid
mailman_host = mail mailman_host = mail
krb5_cache_dir = /run/ceod/krb5_cache
use_https = false use_https = false
port = 9987 port = 9987
@ -43,3 +44,11 @@ api_base_url = http://localhost:8001/3.1
api_username = restadmin api_username = restadmin
api_password = mailman3 api_password = mailman3
new_member_list = csc-general 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

View File

@ -5,6 +5,7 @@ base_domain = csclub.internal
admin_host = phosphoric-acid admin_host = phosphoric-acid
fs_root_host = phosphoric-acid fs_root_host = phosphoric-acid
mailman_host = mail mailman_host = mail
krb5_cache_dir = /tmp/ceod_test_krb5_cache
use_https = false use_https = false
port = 9987 port = 9987

View File

@ -1,7 +1,10 @@
import importlib.resources import importlib.resources
import os import os
import shutil import shutil
import subprocess
import tempfile
import flask
import ldap3 import ldap3
import pytest import pytest
import socket import socket
@ -30,16 +33,16 @@ def cfg():
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def krb_srv(cfg): 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 assert os.geteuid() == 0
# this dance again... ugh # this dance again... ugh
if socket.gethostname() == cfg.get('ceod_admin_host'): if socket.gethostname() == cfg.get('ceod_admin_host'):
principal = 'ceod/admin' principal = 'ceod/admin'
else: else:
principal = 'ceod/' + socket.getfqdn() principal = 'ceod/' + socket.getfqdn()
cache_dir = '/tmp/ceod_test/krb5_cache' cache_dir = cfg.get('ceod_krb5_cache_dir')
shutil.rmtree(cache_dir, ignore_errors=True) krb = KerberosService(principal)
krb = KerberosService(principal, cache_dir)
component.provideUtility(krb, IKerberosService) component.provideUtility(krb, IKerberosService)
yield krb yield krb
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
@ -55,15 +58,35 @@ def delete_subtree(conn: ldap3.Connection, base_dn: str):
pass pass
def get_ldap_conn(server_url: str): @pytest.fixture(scope='session')
return ldap3.Connection( def ceod_admin_creds(cfg, krb_srv):
server_url, auto_bind=True, raise_exceptions=True, """
authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS) 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') @pytest.fixture(scope='session')
def ldap_srv(cfg, krb_srv): def ldap_conn(cfg, ceod_admin_creds) -> ldap3.Connection:
conn = get_ldap_conn(cfg.get('ldap_server_url')) # 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') users_base = cfg.get('ldap_users_base')
groups_base = cfg.get('ldap_groups_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') @pytest.fixture(scope='session')
def uwldap_srv(cfg, ldap_srv): def uwldap_srv(cfg, ldap_conn):
conn = get_ldap_conn(cfg.get('uwldap_server_url')) conn = ldap_conn
base_dn = cfg.get('uwldap_base') base_dn = cfg.get('uwldap_base')
ou = base_dn.split(',', 1)[0].split('=')[1] ou = base_dn.split(',', 1)[0].split('=')[1]
@ -167,6 +190,21 @@ def app(
return 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 @pytest.fixture
def simple_user(): def simple_user():
return User( return User(
@ -187,7 +225,7 @@ def simple_club():
@pytest.fixture @pytest.fixture
def ldap_user(simple_user): def ldap_user(simple_user, g_admin):
simple_user.add_to_ldap() simple_user.add_to_ldap()
yield simple_user yield simple_user
simple_user.remove_from_ldap() simple_user.remove_from_ldap()
@ -209,15 +247,15 @@ def simple_group():
@pytest.fixture @pytest.fixture
def ldap_group(simple_group): def ldap_group(simple_group, g_admin):
simple_group.add_to_ldap() simple_group.add_to_ldap()
yield simple_group yield simple_group
simple_group.remove_from_ldap() simple_group.remove_from_ldap()
@pytest.fixture @pytest.fixture
def uwldap_user(cfg, uwldap_srv): def uwldap_user(cfg, uwldap_srv, ldap_conn):
conn = get_ldap_conn(cfg.get('uwldap_server_url')) conn = ldap_conn
base_dn = cfg.get('uwldap_base') base_dn = cfg.get('uwldap_base')
user = UWLDAPRecord( user = UWLDAPRecord(
uid='test_jdoe', uid='test_jdoe',

View File

@ -1,59 +1,82 @@
from base64 import b64encode
import json import json
import socket import socket
import subprocess
import tempfile
from flask.testing import FlaskClient from flask.testing import FlaskClient
import gssapi import gssapi
import pytest import pytest
from requests import Request from requests import Request
from requests_gssapi import HTTPSPNEGOAuth 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') @pytest.fixture(scope='session')
def client(app): def client(app):
app_client = app.test_client() app_client = app.test_client()
return CeodTestClient(app_client) with tempfile.TemporaryDirectory() as cache_dir:
yield CeodTestClient(app_client, cache_dir)
class CeodTestClient: class CeodTestClient:
def __init__(self, app_client: FlaskClient): def __init__(self, app_client: FlaskClient, cache_dir: str):
cfg = component.getUtility(IConfig)
self.client = app_client self.client = app_client
self.admin_principal = cfg.get('ldap_admin_principal') self.syscom_principal = 'ctdalek'
# this is only used for the HTTPSNEGOAuth # this is only used for the HTTPSNEGOAuth
self.base_url = f'http://{socket.getfqdn()}' 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): def get_auth(self, principal):
if principal in self.cached_auth:
return self.cached_auth[principal]
name = gssapi.Name(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( auth = HTTPSPNEGOAuth(
opportunistic_auth=True, opportunistic_auth=True,
target_name='ceod', target_name='ceod',
creds=creds, creds=creds,
) )
self.cached_auth[principal] = auth
return auth return auth
def get_headers(self, principal): 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)) 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): def request(self, method, path, principal, **kwargs):
if principal is None: if principal is None:
principal = self.admin_principal principal = self.syscom_principal
resp = self.client.open( resp = self.client.open(
path, method=method, headers=self.get_headers(principal), **kwargs) path, method=method, headers=self.get_headers(principal), **kwargs)
status = int(resp.status.split(' ', 1)[0]) status = int(resp.status.split(' ', 1)[0])
try: if resp.headers['content-type'] == 'application/json':
data = json.loads(resp.data) data = json.loads(resp.data)
except json.JSONDecodeError: else:
data = resp.data.decode() data = [json.loads(line) for line in resp.data.splitlines()]
return status, data return status, data
def get(self, path, principal=None, **kwargs): def get(self, path, principal=None, **kwargs):
@ -62,5 +85,8 @@ class CeodTestClient:
def post(self, path, principal=None, **kwargs): def post(self, path, principal=None, **kwargs):
return self.request('POST', path, principal, **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): def delete(self, path, principal=None, **kwargs):
return self.request('DELETE', path, principal, **kwargs) return self.request('DELETE', path, principal, **kwargs)