add members CLI
This commit is contained in:
parent
0974a7471b
commit
6917247fdd
|
@ -0,0 +1,4 @@
|
||||||
|
from .cli import cli
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli(obj={})
|
|
@ -0,0 +1,43 @@
|
||||||
|
import importlib.resources
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import click
|
||||||
|
from zope import component
|
||||||
|
|
||||||
|
from .krb_check import krb_check
|
||||||
|
from .members import members
|
||||||
|
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||||
|
from ceo_common.model import Config, HTTPClient
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx):
|
||||||
|
# ensure ctx exists and is a dict
|
||||||
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
princ = krb_check()
|
||||||
|
user = princ[:princ.index('@')]
|
||||||
|
ctx.obj['user'] = user
|
||||||
|
|
||||||
|
register_services()
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(members)
|
||||||
|
|
||||||
|
|
||||||
|
def register_services():
|
||||||
|
# Config
|
||||||
|
# This is a hack to determine if we're in the dev env or not
|
||||||
|
if socket.getfqdn().endswith('.csclub.internal'):
|
||||||
|
with importlib.resources.path('tests', 'ceo_dev.ini') as p:
|
||||||
|
config_file = p.__fspath__()
|
||||||
|
else:
|
||||||
|
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
|
||||||
|
cfg = Config(config_file)
|
||||||
|
component.provideUtility(cfg, IConfig)
|
||||||
|
|
||||||
|
# HTTPService
|
||||||
|
http_client = HTTPClient()
|
||||||
|
component.provideUtility(http_client, IHTTPClient)
|
|
@ -0,0 +1,28 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import gssapi
|
||||||
|
|
||||||
|
|
||||||
|
def krb_check():
|
||||||
|
"""
|
||||||
|
Spawns a `kinit` process if no credentials are available or the
|
||||||
|
credentials have expired.
|
||||||
|
Returns the principal string 'user@REALM'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
creds = gssapi.Credentials(usage='initiate')
|
||||||
|
except gssapi.raw.misc.GSSError:
|
||||||
|
kinit()
|
||||||
|
creds = gssapi.Credentials(usage='initiate')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = creds.inquire()
|
||||||
|
except gssapi.raw.exceptions.ExpiredCredentialsError:
|
||||||
|
kinit()
|
||||||
|
result = creds.inquire()
|
||||||
|
|
||||||
|
return str(result.name)
|
||||||
|
|
||||||
|
|
||||||
|
def kinit():
|
||||||
|
subprocess.run(['kinit'], check=True)
|
|
@ -0,0 +1,206 @@
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import click
|
||||||
|
from zope import component
|
||||||
|
|
||||||
|
from .utils import http_post, http_get, http_patch, http_delete, \
|
||||||
|
handle_stream_response, handle_sync_response, print_colon_kv, \
|
||||||
|
get_failed_operations
|
||||||
|
from ceo_common.interfaces import IConfig
|
||||||
|
from ceo_common.utils import get_current_term, add_term, get_max_term
|
||||||
|
from ceod.transactions.members import (
|
||||||
|
AddMemberTransaction,
|
||||||
|
DeleteMemberTransaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def members():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help='Add a new member or club rep')
|
||||||
|
@click.argument('username')
|
||||||
|
@click.option('--cn', help='Full name', prompt='Full name')
|
||||||
|
@click.option('--program', required=False, help='Academic program')
|
||||||
|
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
|
||||||
|
help='Number of terms to add', prompt='Number of terms')
|
||||||
|
@click.option('--clubrep', is_flag=True, default=False,
|
||||||
|
help='Add non-member terms instead of member terms')
|
||||||
|
@click.option('--forwarding-address', required=False,
|
||||||
|
help=('Forwarding address to set in ~/.forward. '
|
||||||
|
'Default is UW address. '
|
||||||
|
'Set to the empty string to disable forwarding.'))
|
||||||
|
def add(username, cn, program, num_terms, clubrep, forwarding_address):
|
||||||
|
cfg = component.getUtility(IConfig)
|
||||||
|
uw_domain = cfg.get('uw_domain')
|
||||||
|
|
||||||
|
current_term = get_current_term()
|
||||||
|
terms = [current_term]
|
||||||
|
for _ in range(1, num_terms):
|
||||||
|
term = add_term(terms[-1])
|
||||||
|
terms.append(term)
|
||||||
|
if forwarding_address is None:
|
||||||
|
forwarding_address = username + '@' + uw_domain
|
||||||
|
|
||||||
|
click.echo("The following user will be created:")
|
||||||
|
lines = [
|
||||||
|
('uid', username),
|
||||||
|
('cn', cn),
|
||||||
|
]
|
||||||
|
if program is not None:
|
||||||
|
lines.append(('program', program))
|
||||||
|
if clubrep:
|
||||||
|
lines.append(('non-member terms', ','.join(terms)))
|
||||||
|
else:
|
||||||
|
lines.append(('member terms', ','.join(terms)))
|
||||||
|
if forwarding_address != '':
|
||||||
|
lines.append(('forwarding address', forwarding_address))
|
||||||
|
print_colon_kv(lines)
|
||||||
|
|
||||||
|
click.confirm('Do you want to continue?', abort=True)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'uid': username,
|
||||||
|
'cn': cn,
|
||||||
|
}
|
||||||
|
if program is not None:
|
||||||
|
body['program'] = program
|
||||||
|
if clubrep:
|
||||||
|
body['terms'] = terms
|
||||||
|
else:
|
||||||
|
body['non_member_terms'] = terms
|
||||||
|
if forwarding_address != '':
|
||||||
|
body['forwarding_addresses'] = [forwarding_address]
|
||||||
|
resp = http_post('/api/members', json=body)
|
||||||
|
data = handle_stream_response(resp, AddMemberTransaction.operations)
|
||||||
|
result = data[-1]['result']
|
||||||
|
print_user_lines(result)
|
||||||
|
|
||||||
|
failed_operations = get_failed_operations(data)
|
||||||
|
if 'send_welcome_message' in failed_operations:
|
||||||
|
click.echo(click.style(
|
||||||
|
'Warning: welcome message was not sent. You now need to manually '
|
||||||
|
'send the user their password.', fg='yellow'))
|
||||||
|
|
||||||
|
|
||||||
|
def print_user_lines(result: Dict):
|
||||||
|
"""Pretty-print a user JSON response."""
|
||||||
|
lines = [
|
||||||
|
('uid', result['uid']),
|
||||||
|
('cn', result['cn']),
|
||||||
|
('program', result.get('program', 'Unknown')),
|
||||||
|
('UID number', result['uid_number']),
|
||||||
|
('GID number', result['gid_number']),
|
||||||
|
('login shell', result['login_shell']),
|
||||||
|
('home directory', result['home_directory']),
|
||||||
|
('is a club', result['is_club']),
|
||||||
|
]
|
||||||
|
if 'forwarding_addresses' in result:
|
||||||
|
lines.append(('forwarding addresses', ','.join(result['forwarding_addresses'])))
|
||||||
|
if 'terms' in result:
|
||||||
|
lines.append(('terms', ','.join(result['terms'])))
|
||||||
|
if 'non_member_terms' in result:
|
||||||
|
lines.append(('non-member terms', ','.join(result['non_member_terms'])))
|
||||||
|
if 'password' in result:
|
||||||
|
lines.append(('password', result['password']))
|
||||||
|
print_colon_kv(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help='Get info about a user')
|
||||||
|
@click.argument('username')
|
||||||
|
def get(username):
|
||||||
|
resp = http_get('/api/members/' + username)
|
||||||
|
result = handle_sync_response(resp)
|
||||||
|
print_user_lines(result)
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help="Replace a user's login shell or forwarding addresses")
|
||||||
|
@click.argument('username')
|
||||||
|
@click.option('--login-shell', required=False, help='Login shell')
|
||||||
|
@click.option('--forwarding-addresses', required=False,
|
||||||
|
help='Comma-separated list of forwarding addresses')
|
||||||
|
def modify(username, login_shell, forwarding_addresses):
|
||||||
|
if login_shell is None and forwarding_addresses is None:
|
||||||
|
click.echo('Nothing to do.')
|
||||||
|
sys.exit()
|
||||||
|
operations = []
|
||||||
|
body = {}
|
||||||
|
if login_shell is not None:
|
||||||
|
body['login_shell'] = login_shell
|
||||||
|
operations.append('replace_login_shell')
|
||||||
|
click.echo('Login shell will be set to: ' + login_shell)
|
||||||
|
if forwarding_addresses is not None:
|
||||||
|
forwarding_addresses = forwarding_addresses.split(',')
|
||||||
|
body['forwarding_addresses'] = forwarding_addresses
|
||||||
|
operations.append('replace_forwarding_addresses')
|
||||||
|
prefix = '~/.forward will be set to: '
|
||||||
|
click.echo(prefix + forwarding_addresses[0])
|
||||||
|
for address in forwarding_addresses[1:]:
|
||||||
|
click.echo((' ' * len(prefix)) + address)
|
||||||
|
|
||||||
|
click.confirm('Do you want to continue?', abort=True)
|
||||||
|
|
||||||
|
resp = http_patch('/api/members/' + username, json=body)
|
||||||
|
handle_stream_response(resp, operations)
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help="Renew a member or club rep's membership")
|
||||||
|
@click.argument('username')
|
||||||
|
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
|
||||||
|
help='Number of terms to add', prompt='Number of terms')
|
||||||
|
@click.option('--clubrep', is_flag=True, default=False,
|
||||||
|
help='Add non-member terms instead of member terms')
|
||||||
|
def renew(username, num_terms, clubrep):
|
||||||
|
resp = http_get('/api/members/' + username)
|
||||||
|
result = handle_sync_response(resp)
|
||||||
|
max_term = None
|
||||||
|
if clubrep and 'non_member_terms' in result:
|
||||||
|
max_term = get_max_term(result['non_member_terms'])
|
||||||
|
elif not clubrep and 'terms' in result:
|
||||||
|
max_term = get_max_term(result['terms'])
|
||||||
|
if max_term is not None:
|
||||||
|
max_term = get_max_term([max_term, get_current_term()])
|
||||||
|
else:
|
||||||
|
max_term = get_current_term()
|
||||||
|
|
||||||
|
terms = [add_term(max_term)]
|
||||||
|
for _ in range(1, num_terms):
|
||||||
|
term = add_term(terms[-1])
|
||||||
|
terms.append(term)
|
||||||
|
|
||||||
|
if clubrep:
|
||||||
|
body = {'non_member_terms': terms}
|
||||||
|
click.echo('The following non-member terms will be added: ' + ','.join(terms))
|
||||||
|
else:
|
||||||
|
body = {'terms': terms}
|
||||||
|
click.echo('The following member terms will be added: ' + ','.join(terms))
|
||||||
|
|
||||||
|
click.confirm('Do you want to continue?', abort=True)
|
||||||
|
|
||||||
|
resp = http_post(f'/api/members/{username}/renew', json=body)
|
||||||
|
handle_sync_response(resp)
|
||||||
|
click.echo('Done.')
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help="Reset a user's password")
|
||||||
|
@click.argument('username')
|
||||||
|
def pwreset(username):
|
||||||
|
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True)
|
||||||
|
resp = http_post(f'/api/members/{username}/pwreset')
|
||||||
|
result = handle_sync_response(resp)
|
||||||
|
click.echo('New password: ' + result['password'])
|
||||||
|
|
||||||
|
|
||||||
|
@members.command(short_help="Delete a user")
|
||||||
|
@click.argument('username')
|
||||||
|
def delete(username):
|
||||||
|
# a hack to determine if we're in the dev environment
|
||||||
|
if not socket.getfqdn().endswith('.csclub.internal'):
|
||||||
|
click.echo('This command may only be called during development.')
|
||||||
|
sys.exit(1)
|
||||||
|
click.confirm(f"Are you sure you want to delete {username}?", abort=True)
|
||||||
|
resp = http_delete(f'/api/members/{username}')
|
||||||
|
handle_stream_response(resp, DeleteMemberTransaction.operations)
|
|
@ -0,0 +1,19 @@
|
||||||
|
# These descriptions are printed to the console while a transaction
|
||||||
|
# is performed, in real time.
|
||||||
|
descriptions = {
|
||||||
|
'add_user_to_ldap': 'Add user to LDAP',
|
||||||
|
'add_group_to_ldap': 'Add group to LDAP',
|
||||||
|
'add_user_to_kerberos': 'Add user to Kerberos',
|
||||||
|
'create_home_dir': 'Create home directory',
|
||||||
|
'set_forwarding_addresses': 'Set forwarding addresses',
|
||||||
|
'send_welcome_message': 'Send welcome message',
|
||||||
|
'subscribe_to_mailing_list': 'Subscribe to mailing list',
|
||||||
|
'announce_new_user': 'Announce new user to mailing list',
|
||||||
|
'replace_login_shell': 'Replace login shell',
|
||||||
|
'replace_forwarding_addresses': 'Replace forwarding addresses',
|
||||||
|
'remove_user_from_ldap': 'Remove user from LDAP',
|
||||||
|
'remove_group_from_ldap': 'Remove group from LDAP',
|
||||||
|
'remove_user_from_kerberos': 'Remove user from Kerberos',
|
||||||
|
'delete_home_dir': 'Delete home directory',
|
||||||
|
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list',
|
||||||
|
}
|
|
@ -0,0 +1,149 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import List, Tuple, Dict
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
from zope import component
|
||||||
|
|
||||||
|
from .operation_strings import descriptions as op_desc
|
||||||
|
from ceo_common.interfaces import IHTTPClient, IConfig
|
||||||
|
|
||||||
|
|
||||||
|
def http_request(method: str, path: str, **kwargs) -> requests.Response:
|
||||||
|
client = component.getUtility(IHTTPClient)
|
||||||
|
cfg = component.getUtility(IConfig)
|
||||||
|
if path.startswith('/api/db'):
|
||||||
|
host = cfg.get('ceod_db_host')
|
||||||
|
need_cred = False
|
||||||
|
else:
|
||||||
|
host = cfg.get('ceod_admin_host')
|
||||||
|
# The forwarded TGT is only needed for endpoints which write to LDAP
|
||||||
|
need_cred = method != 'GET'
|
||||||
|
return client.request(
|
||||||
|
host, path, method, principal=None, need_cred=need_cred,
|
||||||
|
stream=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(path: str, **kwargs) -> requests.Response:
|
||||||
|
return http_request('GET', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def http_post(path: str, **kwargs) -> requests.Response:
|
||||||
|
return http_request('POST', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def http_patch(path: str, **kwargs) -> requests.Response:
|
||||||
|
return http_request('PATCH', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def http_delete(path: str, **kwargs) -> requests.Response:
|
||||||
|
return http_request('DELETE', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Print output to the console while operations are being streamed
|
||||||
|
from the server over HTTP.
|
||||||
|
Returns the parsed JSON data streamed from the server.
|
||||||
|
"""
|
||||||
|
if resp.status_code != 200:
|
||||||
|
click.echo('An error occurred:')
|
||||||
|
click.echo(resp.text)
|
||||||
|
sys.exit(1)
|
||||||
|
click.echo(op_desc[operations[0]] + '... ', nl=False)
|
||||||
|
idx = 0
|
||||||
|
data = []
|
||||||
|
for line in resp.iter_lines(decode_unicode=True, chunk_size=8):
|
||||||
|
d = json.loads(line)
|
||||||
|
data.append(d)
|
||||||
|
if d['status'] == 'aborted':
|
||||||
|
click.echo(click.style('ABORTED', fg='red'))
|
||||||
|
click.echo('The transaction was rolled back.')
|
||||||
|
click.echo('The error was: ' + d['error'])
|
||||||
|
click.echo('Please check the ceod logs.')
|
||||||
|
sys.exit(1)
|
||||||
|
elif d['status'] == 'completed':
|
||||||
|
click.echo('Transaction successfully completed.')
|
||||||
|
return data
|
||||||
|
|
||||||
|
operation = d['operation']
|
||||||
|
oper_failed = False
|
||||||
|
err_msg = None
|
||||||
|
prefix = 'failed_to_'
|
||||||
|
if operation.startswith(prefix):
|
||||||
|
operation = operation[len(prefix):]
|
||||||
|
oper_failed = True
|
||||||
|
# sometimes the operation looks like
|
||||||
|
# "failed_to_do_something: error message"
|
||||||
|
if ':' in operation:
|
||||||
|
operation, err_msg = operation.split(': ', 1)
|
||||||
|
|
||||||
|
while idx < len(operations) and operations[idx] != operation:
|
||||||
|
click.echo('Skipped')
|
||||||
|
idx += 1
|
||||||
|
if idx == len(operations):
|
||||||
|
break
|
||||||
|
click.echo(op_desc[operations[idx]] + '... ', nl=False)
|
||||||
|
if idx == len(operations):
|
||||||
|
click.echo('Unrecognized operation: ' + operation)
|
||||||
|
continue
|
||||||
|
if oper_failed:
|
||||||
|
click.echo(click.style('Failed', fg='red'))
|
||||||
|
if err_msg is not None:
|
||||||
|
click.echo(' Error message: ' + err_msg)
|
||||||
|
else:
|
||||||
|
click.echo(click.style('Done', fg='green'))
|
||||||
|
idx += 1
|
||||||
|
if idx < len(operations):
|
||||||
|
click.echo(op_desc[operations[idx]] + '... ', nl=False)
|
||||||
|
|
||||||
|
raise Exception('server response ended abruptly')
|
||||||
|
|
||||||
|
|
||||||
|
def handle_sync_response(resp: requests.Response):
|
||||||
|
"""
|
||||||
|
Exit the program if the request was not successful.
|
||||||
|
Returns the parsed JSON response.
|
||||||
|
"""
|
||||||
|
if resp.status_code // 100 != 2:
|
||||||
|
click.echo('An error occurred:')
|
||||||
|
click.echo(resp.text)
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def print_colon_kv(pairs: List[Tuple[str, str]]):
|
||||||
|
"""
|
||||||
|
Pretty-print a list of key-value pairs such that the key and value
|
||||||
|
columns align.
|
||||||
|
Example:
|
||||||
|
key1: value1
|
||||||
|
key1000: value2
|
||||||
|
"""
|
||||||
|
maxlen = max(len(key) for key, val in pairs)
|
||||||
|
for key, val in pairs:
|
||||||
|
click.echo(key + ': ', nl=False)
|
||||||
|
extra_space = ' ' * (maxlen - len(key))
|
||||||
|
click.echo(extra_space, nl=False)
|
||||||
|
click.echo(val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_failed_operations(data: List[Dict]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get a list of the failed operations using the JSON objects
|
||||||
|
streamed from the server.
|
||||||
|
"""
|
||||||
|
prefix = 'failed_to_'
|
||||||
|
failed = []
|
||||||
|
for d in data:
|
||||||
|
if 'operation' not in d:
|
||||||
|
continue
|
||||||
|
operation = d['operation']
|
||||||
|
if not operation.startswith(prefix):
|
||||||
|
continue
|
||||||
|
operation = operation[len(prefix):]
|
||||||
|
if ':' in operation:
|
||||||
|
operation = operation[:operation.index(':')]
|
||||||
|
failed.append(operation)
|
||||||
|
return failed
|
|
@ -1,14 +1,27 @@
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from zope.interface import Interface
|
from zope.interface import Interface
|
||||||
|
|
||||||
|
|
||||||
class IHTTPClient(Interface):
|
class IHTTPClient(Interface):
|
||||||
"""A helper class for HTTP requests to ceod."""
|
"""A helper class for HTTP requests to ceod."""
|
||||||
|
|
||||||
def get(host: str, api_path: str, **kwargs):
|
def request(host: str, api_path: str, method: str, principal: str,
|
||||||
|
need_cred: bool, **kwargs):
|
||||||
|
"""Make an HTTP request."""
|
||||||
|
|
||||||
|
def get(host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
|
need_cred: bool = True, **kwargs):
|
||||||
"""Make a GET request."""
|
"""Make a GET request."""
|
||||||
|
|
||||||
def post(host: str, api_path: str, **kwargs):
|
def post(host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
|
need_cred: bool = True, **kwargs):
|
||||||
"""Make a POST request."""
|
"""Make a POST request."""
|
||||||
|
|
||||||
def delete(host: str, api_path: str, **kwargs):
|
def patch(host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
|
need_cred: bool = True, **kwargs):
|
||||||
|
"""Make a PATCH request."""
|
||||||
|
|
||||||
|
def delete(host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
|
need_cred: bool = True, **kwargs):
|
||||||
"""Make a DELETE request."""
|
"""Make a DELETE request."""
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from flask import g
|
from base64 import b64encode
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
import gssapi
|
import gssapi
|
||||||
import requests
|
import requests
|
||||||
from requests_gssapi import HTTPSPNEGOAuth
|
from requests_gssapi import HTTPSPNEGOAuth
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
from ceo_common.krb5.utils import get_fwd_tgt
|
||||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||||
|
|
||||||
|
|
||||||
|
@ -20,32 +23,48 @@ 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')
|
||||||
|
|
||||||
self.krb_realm = cfg.get('ldap_sasl_realm')
|
def request(self, host: str, api_path: str, method: str, principal: str,
|
||||||
|
need_cred: bool, **kwargs):
|
||||||
def request(self, host: str, api_path: str, method='GET', **kwargs):
|
|
||||||
principal = g.sasl_user
|
|
||||||
gssapi_name = gssapi.Name(principal)
|
|
||||||
creds = gssapi.Credentials(name=gssapi_name, usage='initiate')
|
|
||||||
# always use the FQDN
|
# always use the FQDN
|
||||||
if '.' not in host:
|
if '.' not in host:
|
||||||
host = host + '.' + self.base_domain
|
host = host + '.' + self.base_domain
|
||||||
|
|
||||||
|
# SPNEGO
|
||||||
|
if principal is not None:
|
||||||
|
gssapi_name = gssapi.Name(principal)
|
||||||
|
creds = gssapi.Credentials(name=gssapi_name, usage='initiate')
|
||||||
|
else:
|
||||||
|
creds = None
|
||||||
auth = HTTPSPNEGOAuth(
|
auth = HTTPSPNEGOAuth(
|
||||||
opportunistic_auth=True,
|
opportunistic_auth=True,
|
||||||
target_name=gssapi.Name('ceod/' + host),
|
target_name=gssapi.Name('ceod/' + host),
|
||||||
creds=creds,
|
creds=creds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Forwarded TGT (X-KRB5-CRED)
|
||||||
|
headers = {}
|
||||||
|
if need_cred:
|
||||||
|
b = get_fwd_tgt('ceod/' + host)
|
||||||
|
headers['X-KRB5-CRED'] = b64encode(b).decode()
|
||||||
|
|
||||||
return requests.request(
|
return requests.request(
|
||||||
method,
|
method,
|
||||||
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
|
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
|
||||||
auth=auth,
|
auth=auth, headers=headers, **kwargs,
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, host: str, api_path: str, **kwargs):
|
def get(self, host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
return self.request(host, api_path, 'GET', **kwargs)
|
need_cred: bool = False, **kwargs):
|
||||||
|
return self.request(host, api_path, 'GET', principal, need_cred, **kwargs)
|
||||||
|
|
||||||
def post(self, host: str, api_path: str, **kwargs):
|
def post(self, host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
return self.request(host, api_path, 'POST', **kwargs)
|
need_cred: bool = False, **kwargs):
|
||||||
|
return self.request(host, api_path, 'POST', principal, need_cred, **kwargs)
|
||||||
|
|
||||||
def delete(self, host: str, api_path: str, **kwargs):
|
def patch(self, host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
return self.request(host, api_path, 'DELETE', **kwargs)
|
need_cred: bool = False, **kwargs):
|
||||||
|
return self.request(host, api_path, 'PATCH', principal, need_cred, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, host: str, api_path: str, principal: Union[str, None] = None,
|
||||||
|
need_cred: bool = False, **kwargs):
|
||||||
|
return self.request(host, api_path, 'DELETE', principal, need_cred, **kwargs)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from flask import g
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
@ -14,7 +15,9 @@ class RemoteMailmanService:
|
||||||
self.http_client = component.getUtility(IHTTPClient)
|
self.http_client = component.getUtility(IHTTPClient)
|
||||||
|
|
||||||
def subscribe(self, address: str, mailing_list: str):
|
def subscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.post(self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
|
resp = self.http_client.post(
|
||||||
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
||||||
|
principal=g.sasl_user)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 409:
|
if resp.status_code == 409:
|
||||||
raise UserAlreadySubscribedError()
|
raise UserAlreadySubscribedError()
|
||||||
|
@ -23,7 +26,9 @@ class RemoteMailmanService:
|
||||||
raise Exception(resp.json())
|
raise Exception(resp.json())
|
||||||
|
|
||||||
def unsubscribe(self, address: str, mailing_list: str):
|
def unsubscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.delete(self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
|
resp = self.http_client.delete(
|
||||||
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
||||||
|
principal=g.sasl_user)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 404:
|
if resp.status_code == 404:
|
||||||
raise UserNotSubscribedError()
|
raise UserNotSubscribedError()
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_term() -> str:
|
||||||
|
"""
|
||||||
|
Get the current term as formatted in the CSC LDAP (e.g. 's2021').
|
||||||
|
"""
|
||||||
|
dt = datetime.datetime.now()
|
||||||
|
c = 'w'
|
||||||
|
if 5 <= dt.month <= 8:
|
||||||
|
c = 's'
|
||||||
|
elif 9 <= dt.month:
|
||||||
|
c = 'f'
|
||||||
|
return c + str(dt.year)
|
||||||
|
|
||||||
|
|
||||||
|
def add_term(term: str) -> str:
|
||||||
|
"""
|
||||||
|
Add one term to the given term and return the string.
|
||||||
|
Example: add_term('s2021') -> 'f2021'
|
||||||
|
"""
|
||||||
|
c = term[0]
|
||||||
|
s_year = term[1:]
|
||||||
|
if c == 'w':
|
||||||
|
return 's' + s_year
|
||||||
|
elif c == 's':
|
||||||
|
return 'f' + s_year
|
||||||
|
year = int(s_year)
|
||||||
|
return 'w' + str(year + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_term(terms: List[str]) -> str:
|
||||||
|
"""Get the maximum (latest) term."""
|
||||||
|
max_year = max(term[1:] for term in terms)
|
||||||
|
if 'f' + max_year in terms:
|
||||||
|
return 'f' + max_year
|
||||||
|
elif 's' + max_year in terms:
|
||||||
|
return 's' + max_year
|
||||||
|
return 'w' + max_year
|
|
@ -84,9 +84,10 @@ def create_streaming_response(txn: AbstractTransaction):
|
||||||
indicating the progress of the transaction.
|
indicating the progress of the transaction.
|
||||||
"""
|
"""
|
||||||
def generate():
|
def generate():
|
||||||
|
generator = txn.execute_iter()
|
||||||
try:
|
try:
|
||||||
for operation in txn.execute_iter():
|
for operation in generator:
|
||||||
operation = yield json.dumps({
|
yield json.dumps({
|
||||||
'status': 'in progress',
|
'status': 'in progress',
|
||||||
'operation': operation,
|
'operation': operation,
|
||||||
}) + '\n'
|
}) + '\n'
|
||||||
|
@ -94,6 +95,15 @@ def create_streaming_response(txn: AbstractTransaction):
|
||||||
'status': 'completed',
|
'status': 'completed',
|
||||||
'result': txn.result,
|
'result': txn.result,
|
||||||
}) + '\n'
|
}) + '\n'
|
||||||
|
except GeneratorExit:
|
||||||
|
# Keep on going. Even if the client closes the connection, we don't
|
||||||
|
# want to give up half way through.
|
||||||
|
try:
|
||||||
|
for operation in generator:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.warning('Transaction failed:\n' + traceback.format_exc())
|
||||||
|
txn.rollback()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.warning('Transaction failed:\n' + traceback.format_exc())
|
logger.warning('Transaction failed:\n' + traceback.format_exc())
|
||||||
txn.rollback()
|
txn.rollback()
|
||||||
|
|
|
@ -22,8 +22,8 @@ class AddMemberTransaction(AbstractTransaction):
|
||||||
'add_user_to_kerberos',
|
'add_user_to_kerberos',
|
||||||
'create_home_dir',
|
'create_home_dir',
|
||||||
'set_forwarding_addresses',
|
'set_forwarding_addresses',
|
||||||
'subscribe_to_mailing_list',
|
|
||||||
'send_welcome_message',
|
'send_welcome_message',
|
||||||
|
'subscribe_to_mailing_list',
|
||||||
'announce_new_user',
|
'announce_new_user',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ class AddMemberTransaction(AbstractTransaction):
|
||||||
|
|
||||||
if self.forwarding_addresses:
|
if self.forwarding_addresses:
|
||||||
user.set_forwarding_addresses(self.forwarding_addresses)
|
user.set_forwarding_addresses(self.forwarding_addresses)
|
||||||
yield 'set_forwarding_addresses'
|
yield 'set_forwarding_addresses'
|
||||||
|
|
||||||
# The following operations can't/shouldn't be rolled back because the
|
# The following operations can't/shouldn't be rolled back because the
|
||||||
# user has already seen the email
|
# user has already seen the email
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
click==8.0.1
|
||||||
Flask==2.0.1
|
Flask==2.0.1
|
||||||
gssapi==1.6.14
|
gssapi==1.6.14
|
||||||
Jinja2==3.0.1
|
Jinja2==3.0.1
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
[DEFAULT]
|
||||||
|
base_domain = csclub.internal
|
||||||
|
uw_domain = uwaterloo.internal
|
||||||
|
|
||||||
|
[ceod]
|
||||||
|
# this is the host with the ceod/admin Kerberos key
|
||||||
|
admin_host = phosphoric-acid
|
||||||
|
use_https = false
|
||||||
|
port = 9987
|
|
@ -18,6 +18,7 @@ def create_user_resp(client, mocks_for_create_user):
|
||||||
'cn': 'Test One',
|
'cn': 'Test One',
|
||||||
'program': 'Math',
|
'program': 'Math',
|
||||||
'terms': ['s2021'],
|
'terms': ['s2021'],
|
||||||
|
'forwarding_addresses': ['test_1@uwaterloo.internal'],
|
||||||
})
|
})
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert data[-1]['status'] == 'completed'
|
assert data[-1]['status'] == 'completed'
|
||||||
|
@ -56,7 +57,7 @@ def test_api_create_user(cfg, create_user_resp):
|
||||||
"is_club": False,
|
"is_club": False,
|
||||||
"program": "Math",
|
"program": "Math",
|
||||||
"terms": ["s2021"],
|
"terms": ["s2021"],
|
||||||
"forwarding_addresses": [],
|
"forwarding_addresses": ['test_1@uwaterloo.internal'],
|
||||||
"password": "krb5"
|
"password": "krb5"
|
||||||
}},
|
}},
|
||||||
]
|
]
|
||||||
|
@ -209,5 +210,5 @@ def test_authz_check(client, create_user_result):
|
||||||
# If we're syscom but we don't pass credentials, the request should fail
|
# If we're syscom but we don't pass credentials, the request should fail
|
||||||
_, data = client.post('/api/members', json={
|
_, data = client.post('/api/members', json={
|
||||||
'uid': 'test_1', 'cn': 'Test One', 'terms': ['s2021'],
|
'uid': 'test_1', 'cn': 'Test One', 'terms': ['s2021'],
|
||||||
}, principal='ctdalek', no_creds=True)
|
}, principal='ctdalek', need_cred=False)
|
||||||
assert data[-1]['status'] == 'aborted'
|
assert data[-1]['status'] == 'aborted'
|
||||||
|
|
|
@ -52,8 +52,6 @@ def delete_test_princs(krb_srv):
|
||||||
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
|
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
|
||||||
], text=True, capture_output=True, check=True)
|
], text=True, capture_output=True, check=True)
|
||||||
princs = [line.strip() for line in proc.stdout.splitlines()]
|
princs = [line.strip() for line in proc.stdout.splitlines()]
|
||||||
# remove the password prompt
|
|
||||||
princs = princs[1:]
|
|
||||||
for princ in princs:
|
for princ in princs:
|
||||||
krb_srv.delprinc(princ)
|
krb_srv.delprinc(princ)
|
||||||
|
|
||||||
|
|
|
@ -72,7 +72,7 @@ class CeodTestClient:
|
||||||
text=True, input='krb5', check=True, stdout=subprocess.DEVNULL,
|
text=True, input='krb5', check=True, stdout=subprocess.DEVNULL,
|
||||||
env={'KRB5CCNAME': self.principal_ccaches[principal]})
|
env={'KRB5CCNAME': self.principal_ccaches[principal]})
|
||||||
|
|
||||||
def get_headers(self, principal: str, no_creds: bool):
|
def get_headers(self, principal: str, need_cred: bool):
|
||||||
if principal not in self.principal_ccaches:
|
if principal not in self.principal_ccaches:
|
||||||
_, filename = tempfile.mkstemp(dir=self.cache_dir)
|
_, filename = tempfile.mkstemp(dir=self.cache_dir)
|
||||||
self.principal_ccaches[principal] = filename
|
self.principal_ccaches[principal] = filename
|
||||||
|
@ -82,7 +82,7 @@ class CeodTestClient:
|
||||||
# the header using req.prepare().
|
# 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))
|
||||||
headers = list(req.prepare().headers.items())
|
headers = list(req.prepare().headers.items())
|
||||||
if not no_creds:
|
if need_cred:
|
||||||
# Get the X-KRB5-CRED header (forwarded TGT).
|
# Get the X-KRB5-CRED header (forwarded TGT).
|
||||||
cred = b64encode(get_fwd_tgt(
|
cred = b64encode(get_fwd_tgt(
|
||||||
'ceod/' + socket.getfqdn(), self.principal_ccaches[principal]
|
'ceod/' + socket.getfqdn(), self.principal_ccaches[principal]
|
||||||
|
@ -90,14 +90,14 @@ class CeodTestClient:
|
||||||
headers.append(('X-KRB5-CRED', cred))
|
headers.append(('X-KRB5-CRED', cred))
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def request(self, method: str, path: str, principal: str, no_creds: bool, **kwargs):
|
def request(self, method: str, path: str, principal: str, need_cred: bool, **kwargs):
|
||||||
# Make sure that we're not already in a request context, otherwise
|
# Make sure that we're not already in a request context, otherwise
|
||||||
# g will get overridden
|
# g will get overridden
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
'' in g
|
'' in g
|
||||||
if principal is None:
|
if principal is None:
|
||||||
principal = self.syscom_principal
|
principal = self.syscom_principal
|
||||||
headers = self.get_headers(principal, no_creds)
|
headers = self.get_headers(principal, need_cred)
|
||||||
resp = self.client.open(path, method=method, headers=headers, **kwargs)
|
resp = self.client.open(path, method=method, headers=headers, **kwargs)
|
||||||
status = int(resp.status.split(' ', 1)[0])
|
status = int(resp.status.split(' ', 1)[0])
|
||||||
if resp.headers['content-type'] == 'application/json':
|
if resp.headers['content-type'] == 'application/json':
|
||||||
|
@ -106,14 +106,14 @@ class CeodTestClient:
|
||||||
data = [json.loads(line) for line in resp.data.splitlines()]
|
data = [json.loads(line) for line in resp.data.splitlines()]
|
||||||
return status, data
|
return status, data
|
||||||
|
|
||||||
def get(self, path, principal=None, no_creds=False, **kwargs):
|
def get(self, path, principal=None, need_cred=True, **kwargs):
|
||||||
return self.request('GET', path, principal, no_creds, **kwargs)
|
return self.request('GET', path, principal, need_cred, **kwargs)
|
||||||
|
|
||||||
def post(self, path, principal=None, no_creds=False, **kwargs):
|
def post(self, path, principal=None, need_cred=True, **kwargs):
|
||||||
return self.request('POST', path, principal, no_creds, **kwargs)
|
return self.request('POST', path, principal, need_cred, **kwargs)
|
||||||
|
|
||||||
def patch(self, path, principal=None, no_creds=False, **kwargs):
|
def patch(self, path, principal=None, need_cred=True, **kwargs):
|
||||||
return self.request('PATCH', path, principal, no_creds, **kwargs)
|
return self.request('PATCH', path, principal, need_cred, **kwargs)
|
||||||
|
|
||||||
def delete(self, path, principal=None, no_creds=False, **kwargs):
|
def delete(self, path, principal=None, need_cred=True, **kwargs):
|
||||||
return self.request('DELETE', path, principal, no_creds, **kwargs)
|
return self.request('DELETE', path, principal, need_cred, **kwargs)
|
||||||
|
|
Loading…
Reference in New Issue